agora inbox for [email protected]help / color / mirror / Atom feed
[PATCH v1 1/2] Support changing a column into a stored generated column 221+ messages / 2 participants [nested] [flat]
* [PATCH v1 1/2] Support changing a column into a stored generated column @ 2026-03-16 23:25 Alberto Piai <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Alberto Piai @ 2026-03-16 23:25 UTC (permalink / raw) This adds basic support for an ALTER TABLE ... ALTER COLUMN command to turn a regular column into a stored generated column. The syntax is chosen to be similar to ... ALTER COLUMN ... ADD GENERATED ... AS IDENTITY with the difference that in this case, since we're dealing with a generated column, only ALWAYS is supported. Additionally, STORED must always be specified. Since this is a first prototype, no thought has been given to partitioned nor foreign tables, so these are not supported either. This operation always rewrites the contents of the column using the new generated expression. --- src/backend/commands/tablecmds.c | 137 +++++++++++++++++- src/backend/parser/gram.y | 31 ++++ src/include/nodes/parsenodes.h | 1 + .../test_ddl_deparse/test_ddl_deparse.c | 2 + src/test/regress/expected/alter_table.out | 122 ++++++++++++++++ src/test/regress/sql/alter_table.sql | 69 +++++++++ 6 files changed, 361 insertions(+), 1 deletion(-) diff --git a/src/backend/commands/tablecmds.c b/src/backend/commands/tablecmds.c index 67e42e5df29..e7386e81b07 100644 --- a/src/backend/commands/tablecmds.c +++ b/src/backend/commands/tablecmds.c @@ -760,6 +760,10 @@ static void ATExecMergePartitions(List **wqueue, AlteredTableInfo *tab, Relation static void ATExecSplitPartition(List **wqueue, AlteredTableInfo *tab, Relation rel, PartitionCmd *cmd, AlterTableUtilityContext *context); +static ObjectAddress ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab, + Relation rel, + const char *colName, + Constraint *def); /* ---------------------------------------------------------------- * DefineRelation @@ -4746,6 +4750,7 @@ AlterTableGetLockLevel(List *cmds) case AT_SetExpression: case AT_DropExpression: case AT_SetCompression: + case AT_AddGeneratedAsExprStored: cmd_lockmode = AccessExclusiveLock; break; @@ -5321,6 +5326,16 @@ ATPrepCmd(List **wqueue, Relation rel, AlterTableCmd *cmd, /* No command-specific prep needed */ pass = AT_PASS_MISC; break; + case AT_AddGeneratedAsExprStored: + /* No support yet for: partitioned tables, foreign tables */ + ATSimplePermissions(cmd->subtype, rel, ATT_TABLE); + + /* + * This has similar mechanics to AT_SetExpression, let's use the + * same pass. + */ + pass = AT_PASS_SET_EXPRESSION; + break; default: /* oops */ elog(ERROR, "unrecognized alter table type: %d", (int) cmd->subtype); @@ -5733,6 +5748,12 @@ ATExecCmd(List **wqueue, AlteredTableInfo *tab, ATExecSplitPartition(wqueue, tab, rel, (PartitionCmd *) cmd->def, context); break; + case AT_AddGeneratedAsExprStored: + cmd = ATParseTransformCmd(wqueue, tab, rel, cmd, false, lockmode, + cur_pass, context); + Assert(cmd != NULL); + address = ATExecAddGeneratedAsExprStored(tab, rel, cmd->name, (Constraint *) cmd->def); + break; default: /* oops */ elog(ERROR, "unrecognized alter table type: %d", (int) cmd->subtype); @@ -6785,6 +6806,8 @@ alter_table_type_to_string(AlterTableType cmdtype) return "ALTER COLUMN ... DROP IDENTITY"; case AT_ReAddStatistics: return NULL; /* not real grammar */ + case AT_AddGeneratedAsExprStored: + return "ALTER COLUMN ... ADD GENERATED ALWAYS AS (...) STORED"; } return NULL; @@ -8823,6 +8846,118 @@ ATExecSetExpression(AlteredTableInfo *tab, Relation rel, const char *colName, return address; } +/* + * ALTER TABLE ALTER COLUMN ADD GENERATED ALWAYS AS expr STORED + */ +static ObjectAddress +ATExecAddGeneratedAsExprStored(AlteredTableInfo *tab, + Relation rel, + const char *colName, + Constraint *def) +{ + HeapTuple tuple; + Form_pg_attribute attTup; + AttrNumber attnum; + ObjectAddress address; + Expr *defval; + NewColumnValue *newval; + RawColumnDefault *rawEnt; + Relation pg_attribute; + + Assert(def->raw_expr != NULL); + Assert(def->cooked_expr == NULL); + Assert(def->generated_when == ATTRIBUTE_IDENTITY_ALWAYS); + Assert(def->generated_kind == ATTRIBUTE_GENERATED_STORED); + + tuple = SearchSysCacheAttName(RelationGetRelid(rel), colName); + if (!HeapTupleIsValid(tuple)) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_COLUMN), + errmsg("column \"%s\" of relation \"%s\" does not exist", + colName, RelationGetRelationName(rel)))); + + attTup = (Form_pg_attribute) GETSTRUCT(tuple); + + attnum = attTup->attnum; + if (attnum <= 0) + ereport(ERROR, + (errcode(ERRCODE_FEATURE_NOT_SUPPORTED), + errmsg("cannot alter system column \"%s\"", + colName))); + + if (attTup->attgenerated) + ereport(ERROR, + (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE), + errmsg("column \"%s\" of relation \"%s\" is already a generated column", + colName, RelationGetRelationName(rel)))); + + /* Mark as generated stored in pg_attribute */ + pg_attribute = table_open(AttributeRelationId, RowExclusiveLock); + attTup->attgenerated = ATTRIBUTE_GENERATED_STORED; + CatalogTupleUpdate(pg_attribute, &tuple->t_self, tuple); + table_close(pg_attribute, RowExclusiveLock); + + /* Make above changes visible */ + CommandCounterIncrement(); + + ReleaseSysCache(tuple); + + /* + * Find everything that depends on the column (constraints, indexes, etc), + * and record enough information to let us recreate the objects. + */ + RememberAllDependentForRebuilding(tab, AT_AddGeneratedAsExprStored, + rel, attnum, colName); + + /* + * Remove previous default value, if any, and store the new generator + * expression. + */ + RemoveAttrDefault(RelationGetRelid(rel), attnum, DROP_RESTRICT, + false, false); + + rawEnt = palloc_object(RawColumnDefault); + rawEnt->attnum = attnum; + rawEnt->raw_default = def->raw_expr; + rawEnt->generated = def->generated_kind; + AddRelationNewConstraints(rel, list_make1(rawEnt), NIL, + false, true, false, NULL); + + /* Make above changes visible */ + CommandCounterIncrement(); + + /* + * Clear all the missing values if we're rewriting the table, since this + * renders them pointless. + */ + RelationClearMissing(rel); + + /* Make above changes visible */ + CommandCounterIncrement(); + + /* Drop any pg_statistic entry for the column */ + RemoveStatistics(RelationGetRelid(rel), attnum); + + /* Build a concrete expression for the new default (generated) value */ + defval = (Expr *) build_column_default(rel, attnum); + defval = expression_planner(defval); + + /* Schedule a rewrite */ + newval = palloc0_object(NewColumnValue); + newval->attnum = attnum; + newval->expr = defval; + newval->is_generated = true; + tab->newvals = lappend(tab->newvals, newval); + tab->rewrite |= AT_REWRITE_DEFAULT_VAL; + + InvokeObjectPostAlterHook(RelationRelationId, + RelationGetRelid(rel), attnum); + + ObjectAddressSubSet(address, RelationRelationId, + RelationGetRelid(rel), attnum); + return address; +} + /* * ALTER TABLE ALTER COLUMN DROP EXPRESSION */ @@ -15290,7 +15425,7 @@ RememberAllDependentForRebuilding(AlteredTableInfo *tab, AlterTableType subtype, SysScanDesc scan; HeapTuple depTup; - Assert(subtype == AT_AlterColumnType || subtype == AT_SetExpression); + Assert(subtype == AT_AlterColumnType || subtype == AT_SetExpression || subtype == AT_AddGeneratedAsExprStored); depRel = table_open(DependRelationId, RowExclusiveLock); diff --git a/src/backend/parser/gram.y b/src/backend/parser/gram.y index c2584249603..74440e801d4 100644 --- a/src/backend/parser/gram.y +++ b/src/backend/parser/gram.y @@ -2717,6 +2717,37 @@ alter_table_cmd: n->name = $3; n->def = (Node *) c; + $$ = (Node *) n; + } + /* ALTER TABLE <name> ALTER [COLUMN] <colname> ADD GENERATED ALWAYS AS ( <expression> ) STORED */ + | ALTER opt_column ColId ADD_P GENERATED generated_when AS '(' a_expr ')' STORED + { + AlterTableCmd *n = makeNode(AlterTableCmd); + Constraint *c = makeNode(Constraint); + + c->contype = CONSTR_GENERATED; + c->generated_when = $6; + c->raw_expr = $9; + c->cooked_expr = NULL; + c->generated_kind = ATTRIBUTE_GENERATED_STORED; + c->location = @5; + + /* + * Like in the case of ColConstraintElem, we cannot handle + * this in the grammar because IDENTITY allows both ALWAYS + * and BY DEFAULT, while generated columns only allow + * ALWAYS. This would lead to shift/reduce conflicts. + */ + if (c->generated_when != ATTRIBUTE_IDENTITY_ALWAYS) + ereport(ERROR, + (errcode(ERRCODE_SYNTAX_ERROR), + errmsg("for a generated column, GENERATED ALWAYS must be specified"), + parser_errposition(@6))); + + n->subtype = AT_AddGeneratedAsExprStored; + n->name = $3; + n->def = (Node *) c; + $$ = (Node *) n; } /* ALTER TABLE <name> ALTER [COLUMN] <colname> SET <sequence options>/RESET */ diff --git a/src/include/nodes/parsenodes.h b/src/include/nodes/parsenodes.h index ffadd667167..6b61513e6d0 100644 --- a/src/include/nodes/parsenodes.h +++ b/src/include/nodes/parsenodes.h @@ -2568,6 +2568,7 @@ typedef enum AlterTableType AT_SetIdentity, /* SET identity column options */ AT_DropIdentity, /* DROP IDENTITY */ AT_ReAddStatistics, /* internal to commands/tablecmds.c */ + AT_AddGeneratedAsExprStored, /* ADD GENERATED ALWAYS AS (...) STORED */ } AlterTableType; typedef struct AlterTableCmd /* one subcommand of an ALTER TABLE */ diff --git a/src/test/modules/test_ddl_deparse/test_ddl_deparse.c b/src/test/modules/test_ddl_deparse/test_ddl_deparse.c index 64a1dfa9f79..7c1699d538c 100644 --- a/src/test/modules/test_ddl_deparse/test_ddl_deparse.c +++ b/src/test/modules/test_ddl_deparse/test_ddl_deparse.c @@ -315,6 +315,8 @@ get_altertable_subcmdinfo(PG_FUNCTION_ARGS) case AT_ReAddStatistics: strtype = "(re) ADD STATS"; break; + case AT_AddGeneratedAsExprStored: + strtype = "ADD GENERATED ALWAYS AS (...) STORED"; } if (subcmd->recurse) diff --git a/src/test/regress/expected/alter_table.out b/src/test/regress/expected/alter_table.out index ccd79dfecc0..75f64628aef 100644 --- a/src/test/regress/expected/alter_table.out +++ b/src/test/regress/expected/alter_table.out @@ -4863,3 +4863,125 @@ drop publication pub1; drop schema alter1 cascade; drop schema alter2 cascade; NOTICE: drop cascades to table alter2.t1 +-- Tests for ALTER COLUMN ... ADD GENERATED ALWAYS as ( expr ) STORED +-- turning a regular column into a stored generated column +create schema testgen; +create table testgen.t1 (a int, b int not null); +insert into testgen.t1 (a, b) + select x, x from generate_series(1, 10) x; +alter table testgen.t1 alter column b + add generated always as (a * 2) stored; +\d+ testgen.t1 + Table "testgen.t1" + Column | Type | Collation | Nullable | Default | Storage | Stats target | Description +--------+---------+-----------+----------+------------------------------------+---------+--------------+------------- + a | integer | | | | plain | | + b | integer | | not null | generated always as (a * 2) stored | plain | | +Not-null constraints: + "t1_b_not_null" NOT NULL "b" + +select a, b, a * 2 as expected, b = (a * 2) as correct + from testgen.t1 order by a; + a | b | expected | correct +----+----+----------+--------- + 1 | 2 | 2 | t + 2 | 4 | 4 | t + 3 | 6 | 6 | t + 4 | 8 | 8 | t + 5 | 10 | 10 | t + 6 | 12 | 12 | t + 7 | 14 | 14 | t + 8 | 16 | 16 | t + 9 | 18 | 18 | t + 10 | 20 | 20 | t +(10 rows) + +insert into testgen.t1 (a, b) values (10, 20); +ERROR: cannot insert a non-DEFAULT value into column "b" +DETAIL: Column "b" is a generated column. +insert into testgen.t1 (a, b) values (10, 21); +ERROR: cannot insert a non-DEFAULT value into column "b" +DETAIL: Column "b" is a generated column. +drop table testgen.t1; +-- turning a regular column into a stored generated column +-- fails when another constraint conflicts with the new expression +create table testgen.t2 (a int, b int not null); +insert into testgen.t2 (a, b) select x, x * 2 from generate_series(0, 5) x; +alter table testgen.t2 add constraint chk_gen_clause check (b = a * 2); +select pg_relation_filenode('testgen.t2') as t2_filenode_before \gset +alter table testgen.t2 alter column b add generated always as (a * 3) stored; +ERROR: check constraint "chk_gen_clause" of relation "t2" is violated by some row +select pg_relation_filenode('testgen.t2') as t2_filenode_after \gset +select :t2_filenode_before = :t2_filenode_after as did_not_rewrite; + did_not_rewrite +----------------- + t +(1 row) + +\d+ testgen.t2 + Table "testgen.t2" + Column | Type | Collation | Nullable | Default | Storage | Stats target | Description +--------+---------+-----------+----------+---------+---------+--------------+------------- + a | integer | | | | plain | | + b | integer | | not null | | plain | | +Check constraints: + "chk_gen_clause" CHECK (b = (a * 2)) +Not-null constraints: + "t2_b_not_null" NOT NULL "b" + +drop table testgen.t2; +-- rewrite an indexed column +create table testgen.t3 (a int, b int); +create index idx_b on testgen.t3 (b); +insert into testgen.t3 (a, b) select x, x from generate_series(1, 10) x; +select pg_relation_filenode('testgen.idx_b') as idx_filenode_before \gset +alter table testgen.t3 alter column b add generated always as (a * 2) stored; +select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset +select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx; + did_rewrite_idx +----------------- + t +(1 row) + +-- tests for invalid invocations +alter table doesnotexist alter column foo + add generated always as (bar * 2) stored; +ERROR: relation "doesnotexist" does not exist +create table testgen.t1 (a int); +alter table testgen.t1 alter column doesnotexist + add generated always as (bar * 2) stored; +ERROR: column "doesnotexist" of relation "t1" does not exist +alter table testgen.t1 add column b int; +alter table testgen.t1 alter column b + add generated always as (doesnotexist * 2) stored; +ERROR: column "doesnotexist" does not exist +-- invalid: only supports ALWAYS +alter table testgen.t1 alter column b + add generated by default as (a * 2) stored; +ERROR: for a generated column, GENERATED ALWAYS must be specified +LINE 2: add generated by default as (a * 2) stored; + ^ +-- invalid: only supports STORED +alter table testgen.t1 alter column b add generated always as (a * 2); +ERROR: syntax error at or near ";" +LINE 1: ...e testgen.t1 alter column b add generated always as (a * 2); + ^ +alter table testgen.t1 alter column b add generated always as (a * 2) virtual; +ERROR: syntax error at or near "virtual" +LINE 1: ...n.t1 alter column b add generated always as (a * 2) virtual; + ^ +-- invalid: b is already a generated column +create table testgen.t2 (a int, b int generated always as (a * 2) stored); +alter table testgen.t2 alter column b add generated always as (a * 2) stored; +ERROR: column "b" of relation "t2" is already a generated column +-- not supported: partitioned tables +create table testgen.tpart (a int, b int) partition by hash (a); +alter table testgen.tpart alter column b add generated always as (a * 2) stored; +ERROR: ALTER action ALTER COLUMN ... ADD GENERATED ALWAYS AS (...) STORED cannot be performed on relation "tpart" +DETAIL: This operation is not supported for partitioned tables. +drop schema testgen cascade; +NOTICE: drop cascades to 4 other objects +DETAIL: drop cascades to table testgen.t3 +drop cascades to table testgen.t1 +drop cascades to table testgen.t2 +drop cascades to table testgen.tpart diff --git a/src/test/regress/sql/alter_table.sql b/src/test/regress/sql/alter_table.sql index f5f13bbd3e7..d776595a6ed 100644 --- a/src/test/regress/sql/alter_table.sql +++ b/src/test/regress/sql/alter_table.sql @@ -3159,3 +3159,72 @@ alter table alter1.t1 set schema alter2; drop publication pub1; drop schema alter1 cascade; drop schema alter2 cascade; + +-- Tests for ALTER COLUMN ... ADD GENERATED ALWAYS as ( expr ) STORED +-- turning a regular column into a stored generated column +create schema testgen; + +create table testgen.t1 (a int, b int not null); +insert into testgen.t1 (a, b) + select x, x from generate_series(1, 10) x; +alter table testgen.t1 alter column b + add generated always as (a * 2) stored; +\d+ testgen.t1 +select a, b, a * 2 as expected, b = (a * 2) as correct + from testgen.t1 order by a; +insert into testgen.t1 (a, b) values (10, 20); +insert into testgen.t1 (a, b) values (10, 21); +drop table testgen.t1; + +-- turning a regular column into a stored generated column +-- fails when another constraint conflicts with the new expression +create table testgen.t2 (a int, b int not null); +insert into testgen.t2 (a, b) select x, x * 2 from generate_series(0, 5) x; +alter table testgen.t2 add constraint chk_gen_clause check (b = a * 2); +select pg_relation_filenode('testgen.t2') as t2_filenode_before \gset +alter table testgen.t2 alter column b add generated always as (a * 3) stored; +select pg_relation_filenode('testgen.t2') as t2_filenode_after \gset +select :t2_filenode_before = :t2_filenode_after as did_not_rewrite; +\d+ testgen.t2 +drop table testgen.t2; + +-- rewrite an indexed column +create table testgen.t3 (a int, b int); +create index idx_b on testgen.t3 (b); +insert into testgen.t3 (a, b) select x, x from generate_series(1, 10) x; +select pg_relation_filenode('testgen.idx_b') as idx_filenode_before \gset +alter table testgen.t3 alter column b add generated always as (a * 2) stored; +select pg_relation_filenode('testgen.idx_b') as idx_filenode_after \gset +select :idx_filenode_before != :idx_filenode_after as did_rewrite_idx; + +-- tests for invalid invocations +alter table doesnotexist alter column foo + add generated always as (bar * 2) stored; + +create table testgen.t1 (a int); + +alter table testgen.t1 alter column doesnotexist + add generated always as (bar * 2) stored; + +alter table testgen.t1 add column b int; + +alter table testgen.t1 alter column b + add generated always as (doesnotexist * 2) stored; + +-- invalid: only supports ALWAYS +alter table testgen.t1 alter column b + add generated by default as (a * 2) stored; + +-- invalid: only supports STORED +alter table testgen.t1 alter column b add generated always as (a * 2); +alter table testgen.t1 alter column b add generated always as (a * 2) virtual; + +-- invalid: b is already a generated column +create table testgen.t2 (a int, b int generated always as (a * 2) stored); +alter table testgen.t2 alter column b add generated always as (a * 2) stored; + +-- not supported: partitioned tables +create table testgen.tpart (a int, b int) partition by hash (a); +alter table testgen.tpart alter column b add generated always as (a * 2) stored; + +drop schema testgen cascade; -- 2.51.2 --24dzbv6kpqxe4yje Content-Type: text/x-patch; charset=utf-8 Content-Disposition: attachment; filename="v1-0002-Try-to-avoid-a-rewrite-when-adding-a-stored-gener.patch" ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 21 +++++- .../expected/role-membership-drop-member.out | 75 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 51 +++++++++++++ 4 files changed, 147 insertions(+), 1 deletion(-) 14.9% src/backend/commands/ 48.1% src/test/isolation/expected/ 36.1% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..375fb527af2 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", + get_rolespec_name(rolespec)))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..5b267ea32c2 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,75 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..989fe996249 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,51 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --Dvg/WemKV0I3T/Od-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 21 +++++- .../expected/role-membership-drop-member.out | 75 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 51 +++++++++++++ 4 files changed, 147 insertions(+), 1 deletion(-) 14.9% src/backend/commands/ 48.1% src/test/isolation/expected/ 36.1% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..375fb527af2 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", + get_rolespec_name(rolespec)))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..5b267ea32c2 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,75 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..989fe996249 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,51 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --Dvg/WemKV0I3T/Od-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 21 +++++- .../expected/role-membership-drop-member.out | 75 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 51 +++++++++++++ 4 files changed, 147 insertions(+), 1 deletion(-) 14.9% src/backend/commands/ 48.1% src/test/isolation/expected/ 36.1% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..375fb527af2 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", + get_rolespec_name(rolespec)))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..5b267ea32c2 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,75 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..989fe996249 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,51 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --Dvg/WemKV0I3T/Od-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 21 +++++- .../expected/role-membership-drop-member.out | 75 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 51 +++++++++++++ 4 files changed, 147 insertions(+), 1 deletion(-) 14.9% src/backend/commands/ 48.1% src/test/isolation/expected/ 36.1% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..375fb527af2 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", + get_rolespec_name(rolespec)))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..5b267ea32c2 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,75 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..989fe996249 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,51 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --Dvg/WemKV0I3T/Od-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 13 +++++- .../expected/role-membership-drop-member.out | 36 ++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 43 +++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) 13.8% src/backend/commands/ 42.9% src/test/isolation/expected/ 42.2% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..05ec753f4f0 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + } result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..e6cae59d2c9 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,36 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..e0140826e52 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,43 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members +# entries. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit + +# ALTER ROLE ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --cOHldBwZEf1OqVbN-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 21 +++++- .../expected/role-membership-drop-member.out | 75 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 51 +++++++++++++ 4 files changed, 147 insertions(+), 1 deletion(-) 14.9% src/backend/commands/ 48.1% src/test/isolation/expected/ 36.1% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..375fb527af2 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", + get_rolespec_name(rolespec)))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..5b267ea32c2 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,75 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..989fe996249 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,51 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --Dvg/WemKV0I3T/Od-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 13 +++++- .../expected/role-membership-drop-member.out | 36 ++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 43 +++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) 13.8% src/backend/commands/ 42.9% src/test/isolation/expected/ 42.2% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..05ec753f4f0 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + } result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..e6cae59d2c9 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,36 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..e0140826e52 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,43 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members +# entries. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit + +# ALTER ROLE ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --cOHldBwZEf1OqVbN-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 13 +++++- .../expected/role-membership-drop-member.out | 36 ++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 43 +++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) 13.8% src/backend/commands/ 42.9% src/test/isolation/expected/ 42.2% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..05ec753f4f0 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + } result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..e6cae59d2c9 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,36 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..e0140826e52 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,43 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members +# entries. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit + +# ALTER ROLE ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --cOHldBwZEf1OqVbN-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 13 +++++- .../expected/role-membership-drop-member.out | 36 ++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 43 +++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) 13.8% src/backend/commands/ 42.9% src/test/isolation/expected/ 42.2% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..05ec753f4f0 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + } result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..e6cae59d2c9 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,36 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..e0140826e52 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,43 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members +# entries. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit + +# ALTER ROLE ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --cOHldBwZEf1OqVbN-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 21 +++++- .../expected/role-membership-drop-member.out | 75 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 51 +++++++++++++ 4 files changed, 147 insertions(+), 1 deletion(-) 14.9% src/backend/commands/ 48.1% src/test/isolation/expected/ 36.1% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..375fb527af2 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", + get_rolespec_name(rolespec)))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..5b267ea32c2 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,75 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..989fe996249 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,51 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --Dvg/WemKV0I3T/Od-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 20 +++- .../expected/role-membership-drop-member.out | 97 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 63 ++++++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) 11.5% src/backend/commands/ 49.2% src/test/isolation/expected/ 38.6% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..ab3cfb0b13f 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role with OID %u does not exist", roleid))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..7daf48395ca --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,97 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans +step s1_begin: BEGIN; +step s1_set_role: SET ROLE regress_role_member_cu; +step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER; +step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member_cu: <... completed> +step s1_reset: RESET ROLE; +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..054326ac535 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,63 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; + CREATE ROLE regress_role_group_cu; + CREATE ROLE regress_role_member_cu; + GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; + DROP ROLE IF EXISTS regress_role_member_cu; + DROP ROLE IF EXISTS regress_role_group_cu; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_set_role { SET ROLE regress_role_member_cu; } +step s1_grant_cu { GRANT regress_role_group_cu TO CURRENT_USER; } +step s1_commit { COMMIT; } +step s1_reset { RESET ROLE; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_drop_member_cu { DROP ROLE regress_role_member_cu; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member and concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER and concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member and concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit + +# GRANT role TO CURRENT_USER and concurrent DROP of the current user +permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans -- 2.34.1 --fotX2lJRknAHpfH+-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 13 +++++- .../expected/role-membership-drop-member.out | 36 ++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 43 +++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) 13.8% src/backend/commands/ 42.9% src/test/isolation/expected/ 42.2% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..05ec753f4f0 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + } result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..e6cae59d2c9 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,36 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..e0140826e52 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,43 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members +# entries. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit + +# ALTER ROLE ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --cOHldBwZEf1OqVbN-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 20 +++- .../expected/role-membership-drop-member.out | 97 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 63 ++++++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) 11.5% src/backend/commands/ 49.2% src/test/isolation/expected/ 38.6% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..ab3cfb0b13f 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role with OID %u does not exist", roleid))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..7daf48395ca --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,97 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans +step s1_begin: BEGIN; +step s1_set_role: SET ROLE regress_role_member_cu; +step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER; +step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member_cu: <... completed> +step s1_reset: RESET ROLE; +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..054326ac535 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,63 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; + CREATE ROLE regress_role_group_cu; + CREATE ROLE regress_role_member_cu; + GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; + DROP ROLE IF EXISTS regress_role_member_cu; + DROP ROLE IF EXISTS regress_role_group_cu; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_set_role { SET ROLE regress_role_member_cu; } +step s1_grant_cu { GRANT regress_role_group_cu TO CURRENT_USER; } +step s1_commit { COMMIT; } +step s1_reset { RESET ROLE; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_drop_member_cu { DROP ROLE regress_role_member_cu; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member and concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER and concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member and concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit + +# GRANT role TO CURRENT_USER and concurrent DROP of the current user +permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans -- 2.34.1 --fotX2lJRknAHpfH+-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 20 +++- .../expected/role-membership-drop-member.out | 97 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 63 ++++++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) 11.5% src/backend/commands/ 49.2% src/test/isolation/expected/ 38.6% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..ab3cfb0b13f 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role with OID %u does not exist", roleid))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..7daf48395ca --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,97 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans +step s1_begin: BEGIN; +step s1_set_role: SET ROLE regress_role_member_cu; +step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER; +step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member_cu: <... completed> +step s1_reset: RESET ROLE; +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..054326ac535 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,63 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; + CREATE ROLE regress_role_group_cu; + CREATE ROLE regress_role_member_cu; + GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; + DROP ROLE IF EXISTS regress_role_member_cu; + DROP ROLE IF EXISTS regress_role_group_cu; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_set_role { SET ROLE regress_role_member_cu; } +step s1_grant_cu { GRANT regress_role_group_cu TO CURRENT_USER; } +step s1_commit { COMMIT; } +step s1_reset { RESET ROLE; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_drop_member_cu { DROP ROLE regress_role_member_cu; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member and concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER and concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member and concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit + +# GRANT role TO CURRENT_USER and concurrent DROP of the current user +permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans -- 2.34.1 --fotX2lJRknAHpfH+-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 20 +++- .../expected/role-membership-drop-member.out | 97 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 63 ++++++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) 11.5% src/backend/commands/ 49.2% src/test/isolation/expected/ 38.6% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..ab3cfb0b13f 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role with OID %u does not exist", roleid))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..7daf48395ca --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,97 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans +step s1_begin: BEGIN; +step s1_set_role: SET ROLE regress_role_member_cu; +step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER; +step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member_cu: <... completed> +step s1_reset: RESET ROLE; +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..054326ac535 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,63 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; + CREATE ROLE regress_role_group_cu; + CREATE ROLE regress_role_member_cu; + GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; + DROP ROLE IF EXISTS regress_role_member_cu; + DROP ROLE IF EXISTS regress_role_group_cu; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_set_role { SET ROLE regress_role_member_cu; } +step s1_grant_cu { GRANT regress_role_group_cu TO CURRENT_USER; } +step s1_commit { COMMIT; } +step s1_reset { RESET ROLE; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_drop_member_cu { DROP ROLE regress_role_member_cu; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member and concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER and concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member and concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit + +# GRANT role TO CURRENT_USER and concurrent DROP of the current user +permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans -- 2.34.1 --fotX2lJRknAHpfH+-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 13 +++++- .../expected/role-membership-drop-member.out | 36 ++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 43 +++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) 13.8% src/backend/commands/ 42.9% src/test/isolation/expected/ 42.2% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..05ec753f4f0 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + } result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..e6cae59d2c9 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,36 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..e0140826e52 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,43 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members +# entries. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit + +# ALTER ROLE ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --cOHldBwZEf1OqVbN-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 13 +++++- .../expected/role-membership-drop-member.out | 36 ++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 43 +++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) 13.8% src/backend/commands/ 42.9% src/test/isolation/expected/ 42.2% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..05ec753f4f0 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + } result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..e6cae59d2c9 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,36 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..e0140826e52 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,43 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members +# entries. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit + +# ALTER ROLE ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --cOHldBwZEf1OqVbN-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 21 +++++- .../expected/role-membership-drop-member.out | 75 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 51 +++++++++++++ 4 files changed, 147 insertions(+), 1 deletion(-) 14.9% src/backend/commands/ 48.1% src/test/isolation/expected/ 36.1% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..375fb527af2 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", + get_rolespec_name(rolespec)))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..5b267ea32c2 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,75 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..989fe996249 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,51 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --Dvg/WemKV0I3T/Od-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 20 +++- .../expected/role-membership-drop-member.out | 97 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 63 ++++++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) 11.5% src/backend/commands/ 49.2% src/test/isolation/expected/ 38.6% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..ab3cfb0b13f 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role with OID %u does not exist", roleid))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..7daf48395ca --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,97 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans +step s1_begin: BEGIN; +step s1_set_role: SET ROLE regress_role_member_cu; +step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER; +step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member_cu: <... completed> +step s1_reset: RESET ROLE; +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..054326ac535 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,63 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; + CREATE ROLE regress_role_group_cu; + CREATE ROLE regress_role_member_cu; + GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; + DROP ROLE IF EXISTS regress_role_member_cu; + DROP ROLE IF EXISTS regress_role_group_cu; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_set_role { SET ROLE regress_role_member_cu; } +step s1_grant_cu { GRANT regress_role_group_cu TO CURRENT_USER; } +step s1_commit { COMMIT; } +step s1_reset { RESET ROLE; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_drop_member_cu { DROP ROLE regress_role_member_cu; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member and concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER and concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member and concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit + +# GRANT role TO CURRENT_USER and concurrent DROP of the current user +permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans -- 2.34.1 --fotX2lJRknAHpfH+-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 13 +++++- .../expected/role-membership-drop-member.out | 36 ++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 43 +++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) 13.8% src/backend/commands/ 42.9% src/test/isolation/expected/ 42.2% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..05ec753f4f0 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + } result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..e6cae59d2c9 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,36 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..e0140826e52 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,43 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members +# entries. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit + +# ALTER ROLE ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --cOHldBwZEf1OqVbN-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 21 +++++- .../expected/role-membership-drop-member.out | 75 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 51 +++++++++++++ 4 files changed, 147 insertions(+), 1 deletion(-) 14.9% src/backend/commands/ 48.1% src/test/isolation/expected/ 36.1% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..375fb527af2 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", + get_rolespec_name(rolespec)))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..5b267ea32c2 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,75 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..989fe996249 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,51 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --Dvg/WemKV0I3T/Od-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 20 +++- .../expected/role-membership-drop-member.out | 97 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 63 ++++++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) 11.5% src/backend/commands/ 49.2% src/test/isolation/expected/ 38.6% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..ab3cfb0b13f 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role with OID %u does not exist", roleid))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..7daf48395ca --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,97 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans +step s1_begin: BEGIN; +step s1_set_role: SET ROLE regress_role_member_cu; +step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER; +step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member_cu: <... completed> +step s1_reset: RESET ROLE; +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..054326ac535 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,63 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; + CREATE ROLE regress_role_group_cu; + CREATE ROLE regress_role_member_cu; + GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; + DROP ROLE IF EXISTS regress_role_member_cu; + DROP ROLE IF EXISTS regress_role_group_cu; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_set_role { SET ROLE regress_role_member_cu; } +step s1_grant_cu { GRANT regress_role_group_cu TO CURRENT_USER; } +step s1_commit { COMMIT; } +step s1_reset { RESET ROLE; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_drop_member_cu { DROP ROLE regress_role_member_cu; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member and concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER and concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member and concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit + +# GRANT role TO CURRENT_USER and concurrent DROP of the current user +permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans -- 2.34.1 --fotX2lJRknAHpfH+-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 20 +++- .../expected/role-membership-drop-member.out | 97 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 63 ++++++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) 11.5% src/backend/commands/ 49.2% src/test/isolation/expected/ 38.6% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..ab3cfb0b13f 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role with OID %u does not exist", roleid))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..7daf48395ca --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,97 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans +step s1_begin: BEGIN; +step s1_set_role: SET ROLE regress_role_member_cu; +step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER; +step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member_cu: <... completed> +step s1_reset: RESET ROLE; +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..054326ac535 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,63 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; + CREATE ROLE regress_role_group_cu; + CREATE ROLE regress_role_member_cu; + GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; + DROP ROLE IF EXISTS regress_role_member_cu; + DROP ROLE IF EXISTS regress_role_group_cu; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_set_role { SET ROLE regress_role_member_cu; } +step s1_grant_cu { GRANT regress_role_group_cu TO CURRENT_USER; } +step s1_commit { COMMIT; } +step s1_reset { RESET ROLE; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_drop_member_cu { DROP ROLE regress_role_member_cu; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member and concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER and concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member and concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit + +# GRANT role TO CURRENT_USER and concurrent DROP of the current user +permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans -- 2.34.1 --fotX2lJRknAHpfH+-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 13 +++++- .../expected/role-membership-drop-member.out | 36 ++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 43 +++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) 13.8% src/backend/commands/ 42.9% src/test/isolation/expected/ 42.2% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..05ec753f4f0 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + } result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..e6cae59d2c9 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,36 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..e0140826e52 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,43 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members +# entries. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit + +# ALTER ROLE ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --cOHldBwZEf1OqVbN-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 20 +++- .../expected/role-membership-drop-member.out | 97 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 63 ++++++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) 11.5% src/backend/commands/ 49.2% src/test/isolation/expected/ 38.6% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..ab3cfb0b13f 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role with OID %u does not exist", roleid))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..7daf48395ca --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,97 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans +step s1_begin: BEGIN; +step s1_set_role: SET ROLE regress_role_member_cu; +step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER; +step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member_cu: <... completed> +step s1_reset: RESET ROLE; +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..054326ac535 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,63 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; + CREATE ROLE regress_role_group_cu; + CREATE ROLE regress_role_member_cu; + GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; + DROP ROLE IF EXISTS regress_role_member_cu; + DROP ROLE IF EXISTS regress_role_group_cu; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_set_role { SET ROLE regress_role_member_cu; } +step s1_grant_cu { GRANT regress_role_group_cu TO CURRENT_USER; } +step s1_commit { COMMIT; } +step s1_reset { RESET ROLE; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_drop_member_cu { DROP ROLE regress_role_member_cu; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member and concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER and concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member and concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit + +# GRANT role TO CURRENT_USER and concurrent DROP of the current user +permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans -- 2.34.1 --fotX2lJRknAHpfH+-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 13 +++++- .../expected/role-membership-drop-member.out | 36 ++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 43 +++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) 13.8% src/backend/commands/ 42.9% src/test/isolation/expected/ 42.2% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..05ec753f4f0 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + } result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..e6cae59d2c9 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,36 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..e0140826e52 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,43 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members +# entries. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit + +# ALTER ROLE ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --cOHldBwZEf1OqVbN-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 13 +++++- .../expected/role-membership-drop-member.out | 36 ++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 43 +++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) 13.8% src/backend/commands/ 42.9% src/test/isolation/expected/ 42.2% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..05ec753f4f0 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + } result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..e6cae59d2c9 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,36 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..e0140826e52 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,43 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members +# entries. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit + +# ALTER ROLE ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --cOHldBwZEf1OqVbN-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 21 +++++- .../expected/role-membership-drop-member.out | 75 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 51 +++++++++++++ 4 files changed, 147 insertions(+), 1 deletion(-) 14.9% src/backend/commands/ 48.1% src/test/isolation/expected/ 36.1% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..375fb527af2 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", + get_rolespec_name(rolespec)))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..5b267ea32c2 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,75 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..989fe996249 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,51 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --Dvg/WemKV0I3T/Od-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 13 +++++- .../expected/role-membership-drop-member.out | 36 ++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 43 +++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) 13.8% src/backend/commands/ 42.9% src/test/isolation/expected/ 42.2% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..05ec753f4f0 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + } result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..e6cae59d2c9 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,36 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..e0140826e52 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,43 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members +# entries. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit + +# ALTER ROLE ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --cOHldBwZEf1OqVbN-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 21 +++++- .../expected/role-membership-drop-member.out | 75 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 51 +++++++++++++ 4 files changed, 147 insertions(+), 1 deletion(-) 14.9% src/backend/commands/ 48.1% src/test/isolation/expected/ 36.1% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..375fb527af2 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", + get_rolespec_name(rolespec)))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..5b267ea32c2 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,75 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..989fe996249 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,51 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --Dvg/WemKV0I3T/Od-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 21 +++++- .../expected/role-membership-drop-member.out | 75 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 51 +++++++++++++ 4 files changed, 147 insertions(+), 1 deletion(-) 14.9% src/backend/commands/ 48.1% src/test/isolation/expected/ 36.1% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..375fb527af2 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", + get_rolespec_name(rolespec)))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..5b267ea32c2 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,75 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..989fe996249 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,51 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --Dvg/WemKV0I3T/Od-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 20 +++- .../expected/role-membership-drop-member.out | 97 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 63 ++++++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) 11.5% src/backend/commands/ 49.2% src/test/isolation/expected/ 38.6% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..ab3cfb0b13f 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role with OID %u does not exist", roleid))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..7daf48395ca --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,97 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans +step s1_begin: BEGIN; +step s1_set_role: SET ROLE regress_role_member_cu; +step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER; +step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member_cu: <... completed> +step s1_reset: RESET ROLE; +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..054326ac535 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,63 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; + CREATE ROLE regress_role_group_cu; + CREATE ROLE regress_role_member_cu; + GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; + DROP ROLE IF EXISTS regress_role_member_cu; + DROP ROLE IF EXISTS regress_role_group_cu; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_set_role { SET ROLE regress_role_member_cu; } +step s1_grant_cu { GRANT regress_role_group_cu TO CURRENT_USER; } +step s1_commit { COMMIT; } +step s1_reset { RESET ROLE; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_drop_member_cu { DROP ROLE regress_role_member_cu; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member and concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER and concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member and concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit + +# GRANT role TO CURRENT_USER and concurrent DROP of the current user +permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans -- 2.34.1 --fotX2lJRknAHpfH+-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 13 +++++- .../expected/role-membership-drop-member.out | 36 ++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 43 +++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) 13.8% src/backend/commands/ 42.9% src/test/isolation/expected/ 42.2% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..05ec753f4f0 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + } result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..e6cae59d2c9 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,36 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..e0140826e52 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,43 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members +# entries. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit + +# ALTER ROLE ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --cOHldBwZEf1OqVbN-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 13 +++++- .../expected/role-membership-drop-member.out | 36 ++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 43 +++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) 13.8% src/backend/commands/ 42.9% src/test/isolation/expected/ 42.2% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..05ec753f4f0 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + } result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..e6cae59d2c9 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,36 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..e0140826e52 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,43 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members +# entries. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit + +# ALTER ROLE ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --cOHldBwZEf1OqVbN-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 13 +++++- .../expected/role-membership-drop-member.out | 36 ++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 43 +++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) 13.8% src/backend/commands/ 42.9% src/test/isolation/expected/ 42.2% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..05ec753f4f0 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + } result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..e6cae59d2c9 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,36 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..e0140826e52 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,43 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members +# entries. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit + +# ALTER ROLE ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --cOHldBwZEf1OqVbN-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 21 +++++- .../expected/role-membership-drop-member.out | 75 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 51 +++++++++++++ 4 files changed, 147 insertions(+), 1 deletion(-) 14.9% src/backend/commands/ 48.1% src/test/isolation/expected/ 36.1% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..375fb527af2 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", + get_rolespec_name(rolespec)))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..5b267ea32c2 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,75 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..989fe996249 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,51 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --Dvg/WemKV0I3T/Od-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 21 +++++- .../expected/role-membership-drop-member.out | 75 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 51 +++++++++++++ 4 files changed, 147 insertions(+), 1 deletion(-) 14.9% src/backend/commands/ 48.1% src/test/isolation/expected/ 36.1% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..375fb527af2 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", + get_rolespec_name(rolespec)))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..5b267ea32c2 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,75 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..989fe996249 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,51 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --Dvg/WemKV0I3T/Od-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 20 +++- .../expected/role-membership-drop-member.out | 97 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 63 ++++++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) 11.5% src/backend/commands/ 49.2% src/test/isolation/expected/ 38.6% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..ab3cfb0b13f 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role with OID %u does not exist", roleid))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..7daf48395ca --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,97 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans +step s1_begin: BEGIN; +step s1_set_role: SET ROLE regress_role_member_cu; +step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER; +step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member_cu: <... completed> +step s1_reset: RESET ROLE; +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..054326ac535 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,63 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; + CREATE ROLE regress_role_group_cu; + CREATE ROLE regress_role_member_cu; + GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; + DROP ROLE IF EXISTS regress_role_member_cu; + DROP ROLE IF EXISTS regress_role_group_cu; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_set_role { SET ROLE regress_role_member_cu; } +step s1_grant_cu { GRANT regress_role_group_cu TO CURRENT_USER; } +step s1_commit { COMMIT; } +step s1_reset { RESET ROLE; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_drop_member_cu { DROP ROLE regress_role_member_cu; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member and concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER and concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member and concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit + +# GRANT role TO CURRENT_USER and concurrent DROP of the current user +permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans -- 2.34.1 --fotX2lJRknAHpfH+-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 20 +++- .../expected/role-membership-drop-member.out | 97 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 63 ++++++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) 11.5% src/backend/commands/ 49.2% src/test/isolation/expected/ 38.6% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..ab3cfb0b13f 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role with OID %u does not exist", roleid))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..7daf48395ca --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,97 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans +step s1_begin: BEGIN; +step s1_set_role: SET ROLE regress_role_member_cu; +step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER; +step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member_cu: <... completed> +step s1_reset: RESET ROLE; +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..054326ac535 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,63 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; + CREATE ROLE regress_role_group_cu; + CREATE ROLE regress_role_member_cu; + GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; + DROP ROLE IF EXISTS regress_role_member_cu; + DROP ROLE IF EXISTS regress_role_group_cu; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_set_role { SET ROLE regress_role_member_cu; } +step s1_grant_cu { GRANT regress_role_group_cu TO CURRENT_USER; } +step s1_commit { COMMIT; } +step s1_reset { RESET ROLE; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_drop_member_cu { DROP ROLE regress_role_member_cu; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member and concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER and concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member and concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit + +# GRANT role TO CURRENT_USER and concurrent DROP of the current user +permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans -- 2.34.1 --fotX2lJRknAHpfH+-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 20 +++- .../expected/role-membership-drop-member.out | 97 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 63 ++++++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) 11.5% src/backend/commands/ 49.2% src/test/isolation/expected/ 38.6% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..ab3cfb0b13f 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role with OID %u does not exist", roleid))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..7daf48395ca --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,97 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans +step s1_begin: BEGIN; +step s1_set_role: SET ROLE regress_role_member_cu; +step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER; +step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member_cu: <... completed> +step s1_reset: RESET ROLE; +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..054326ac535 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,63 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; + CREATE ROLE regress_role_group_cu; + CREATE ROLE regress_role_member_cu; + GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; + DROP ROLE IF EXISTS regress_role_member_cu; + DROP ROLE IF EXISTS regress_role_group_cu; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_set_role { SET ROLE regress_role_member_cu; } +step s1_grant_cu { GRANT regress_role_group_cu TO CURRENT_USER; } +step s1_commit { COMMIT; } +step s1_reset { RESET ROLE; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_drop_member_cu { DROP ROLE regress_role_member_cu; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member and concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER and concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member and concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit + +# GRANT role TO CURRENT_USER and concurrent DROP of the current user +permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans -- 2.34.1 --fotX2lJRknAHpfH+-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 20 +++- .../expected/role-membership-drop-member.out | 97 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 63 ++++++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) 11.5% src/backend/commands/ 49.2% src/test/isolation/expected/ 38.6% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..ab3cfb0b13f 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role with OID %u does not exist", roleid))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..7daf48395ca --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,97 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans +step s1_begin: BEGIN; +step s1_set_role: SET ROLE regress_role_member_cu; +step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER; +step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member_cu: <... completed> +step s1_reset: RESET ROLE; +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..054326ac535 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,63 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; + CREATE ROLE regress_role_group_cu; + CREATE ROLE regress_role_member_cu; + GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; + DROP ROLE IF EXISTS regress_role_member_cu; + DROP ROLE IF EXISTS regress_role_group_cu; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_set_role { SET ROLE regress_role_member_cu; } +step s1_grant_cu { GRANT regress_role_group_cu TO CURRENT_USER; } +step s1_commit { COMMIT; } +step s1_reset { RESET ROLE; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_drop_member_cu { DROP ROLE regress_role_member_cu; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member and concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER and concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member and concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit + +# GRANT role TO CURRENT_USER and concurrent DROP of the current user +permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans -- 2.34.1 --fotX2lJRknAHpfH+-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 13 +++++- .../expected/role-membership-drop-member.out | 36 ++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 43 +++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) 13.8% src/backend/commands/ 42.9% src/test/isolation/expected/ 42.2% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..05ec753f4f0 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + } result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..e6cae59d2c9 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,36 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..e0140826e52 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,43 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members +# entries. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit + +# ALTER ROLE ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --cOHldBwZEf1OqVbN-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 13 +++++- .../expected/role-membership-drop-member.out | 36 ++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 43 +++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) 13.8% src/backend/commands/ 42.9% src/test/isolation/expected/ 42.2% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..05ec753f4f0 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + } result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..e6cae59d2c9 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,36 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..e0140826e52 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,43 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members +# entries. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit + +# ALTER ROLE ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --cOHldBwZEf1OqVbN-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 21 +++++- .../expected/role-membership-drop-member.out | 75 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 51 +++++++++++++ 4 files changed, 147 insertions(+), 1 deletion(-) 14.9% src/backend/commands/ 48.1% src/test/isolation/expected/ 36.1% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..375fb527af2 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", + get_rolespec_name(rolespec)))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..5b267ea32c2 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,75 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..989fe996249 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,51 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --Dvg/WemKV0I3T/Od-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 21 +++++- .../expected/role-membership-drop-member.out | 75 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 51 +++++++++++++ 4 files changed, 147 insertions(+), 1 deletion(-) 14.9% src/backend/commands/ 48.1% src/test/isolation/expected/ 36.1% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..375fb527af2 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", + get_rolespec_name(rolespec)))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..5b267ea32c2 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,75 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..989fe996249 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,51 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --Dvg/WemKV0I3T/Od-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 20 +++- .../expected/role-membership-drop-member.out | 97 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 63 ++++++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) 11.5% src/backend/commands/ 49.2% src/test/isolation/expected/ 38.6% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..ab3cfb0b13f 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role with OID %u does not exist", roleid))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..7daf48395ca --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,97 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans +step s1_begin: BEGIN; +step s1_set_role: SET ROLE regress_role_member_cu; +step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER; +step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member_cu: <... completed> +step s1_reset: RESET ROLE; +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..054326ac535 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,63 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; + CREATE ROLE regress_role_group_cu; + CREATE ROLE regress_role_member_cu; + GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; + DROP ROLE IF EXISTS regress_role_member_cu; + DROP ROLE IF EXISTS regress_role_group_cu; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_set_role { SET ROLE regress_role_member_cu; } +step s1_grant_cu { GRANT regress_role_group_cu TO CURRENT_USER; } +step s1_commit { COMMIT; } +step s1_reset { RESET ROLE; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_drop_member_cu { DROP ROLE regress_role_member_cu; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member and concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER and concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member and concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit + +# GRANT role TO CURRENT_USER and concurrent DROP of the current user +permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans -- 2.34.1 --fotX2lJRknAHpfH+-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 20 +++- .../expected/role-membership-drop-member.out | 97 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 63 ++++++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) 11.5% src/backend/commands/ 49.2% src/test/isolation/expected/ 38.6% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..ab3cfb0b13f 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role with OID %u does not exist", roleid))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..7daf48395ca --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,97 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans +step s1_begin: BEGIN; +step s1_set_role: SET ROLE regress_role_member_cu; +step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER; +step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member_cu: <... completed> +step s1_reset: RESET ROLE; +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..054326ac535 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,63 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; + CREATE ROLE regress_role_group_cu; + CREATE ROLE regress_role_member_cu; + GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; + DROP ROLE IF EXISTS regress_role_member_cu; + DROP ROLE IF EXISTS regress_role_group_cu; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_set_role { SET ROLE regress_role_member_cu; } +step s1_grant_cu { GRANT regress_role_group_cu TO CURRENT_USER; } +step s1_commit { COMMIT; } +step s1_reset { RESET ROLE; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_drop_member_cu { DROP ROLE regress_role_member_cu; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member and concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER and concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member and concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit + +# GRANT role TO CURRENT_USER and concurrent DROP of the current user +permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans -- 2.34.1 --fotX2lJRknAHpfH+-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 21 +++++- .../expected/role-membership-drop-member.out | 75 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 51 +++++++++++++ 4 files changed, 147 insertions(+), 1 deletion(-) 14.9% src/backend/commands/ 48.1% src/test/isolation/expected/ 36.1% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..375fb527af2 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", + get_rolespec_name(rolespec)))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..5b267ea32c2 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,75 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..989fe996249 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,51 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --Dvg/WemKV0I3T/Od-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 20 +++- .../expected/role-membership-drop-member.out | 97 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 63 ++++++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) 11.5% src/backend/commands/ 49.2% src/test/isolation/expected/ 38.6% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..ab3cfb0b13f 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role with OID %u does not exist", roleid))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..7daf48395ca --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,97 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans +step s1_begin: BEGIN; +step s1_set_role: SET ROLE regress_role_member_cu; +step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER; +step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member_cu: <... completed> +step s1_reset: RESET ROLE; +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..054326ac535 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,63 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; + CREATE ROLE regress_role_group_cu; + CREATE ROLE regress_role_member_cu; + GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; + DROP ROLE IF EXISTS regress_role_member_cu; + DROP ROLE IF EXISTS regress_role_group_cu; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_set_role { SET ROLE regress_role_member_cu; } +step s1_grant_cu { GRANT regress_role_group_cu TO CURRENT_USER; } +step s1_commit { COMMIT; } +step s1_reset { RESET ROLE; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_drop_member_cu { DROP ROLE regress_role_member_cu; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member and concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER and concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member and concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit + +# GRANT role TO CURRENT_USER and concurrent DROP of the current user +permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans -- 2.34.1 --fotX2lJRknAHpfH+-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 20 +++- .../expected/role-membership-drop-member.out | 97 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 63 ++++++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) 11.5% src/backend/commands/ 49.2% src/test/isolation/expected/ 38.6% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..ab3cfb0b13f 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role with OID %u does not exist", roleid))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..7daf48395ca --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,97 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans +step s1_begin: BEGIN; +step s1_set_role: SET ROLE regress_role_member_cu; +step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER; +step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member_cu: <... completed> +step s1_reset: RESET ROLE; +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..054326ac535 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,63 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; + CREATE ROLE regress_role_group_cu; + CREATE ROLE regress_role_member_cu; + GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; + DROP ROLE IF EXISTS regress_role_member_cu; + DROP ROLE IF EXISTS regress_role_group_cu; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_set_role { SET ROLE regress_role_member_cu; } +step s1_grant_cu { GRANT regress_role_group_cu TO CURRENT_USER; } +step s1_commit { COMMIT; } +step s1_reset { RESET ROLE; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_drop_member_cu { DROP ROLE regress_role_member_cu; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member and concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER and concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member and concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit + +# GRANT role TO CURRENT_USER and concurrent DROP of the current user +permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans -- 2.34.1 --fotX2lJRknAHpfH+-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 21 +++++- .../expected/role-membership-drop-member.out | 75 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 51 +++++++++++++ 4 files changed, 147 insertions(+), 1 deletion(-) 14.9% src/backend/commands/ 48.1% src/test/isolation/expected/ 36.1% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..375fb527af2 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", + get_rolespec_name(rolespec)))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..5b267ea32c2 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,75 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..989fe996249 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,51 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --Dvg/WemKV0I3T/Od-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 20 +++- .../expected/role-membership-drop-member.out | 97 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 63 ++++++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) 11.5% src/backend/commands/ 49.2% src/test/isolation/expected/ 38.6% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..ab3cfb0b13f 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role with OID %u does not exist", roleid))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..7daf48395ca --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,97 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans +step s1_begin: BEGIN; +step s1_set_role: SET ROLE regress_role_member_cu; +step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER; +step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member_cu: <... completed> +step s1_reset: RESET ROLE; +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..054326ac535 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,63 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; + CREATE ROLE regress_role_group_cu; + CREATE ROLE regress_role_member_cu; + GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; + DROP ROLE IF EXISTS regress_role_member_cu; + DROP ROLE IF EXISTS regress_role_group_cu; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_set_role { SET ROLE regress_role_member_cu; } +step s1_grant_cu { GRANT regress_role_group_cu TO CURRENT_USER; } +step s1_commit { COMMIT; } +step s1_reset { RESET ROLE; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_drop_member_cu { DROP ROLE regress_role_member_cu; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member and concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER and concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member and concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit + +# GRANT role TO CURRENT_USER and concurrent DROP of the current user +permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans -- 2.34.1 --fotX2lJRknAHpfH+-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 13 +++++- .../expected/role-membership-drop-member.out | 36 ++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 43 +++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) 13.8% src/backend/commands/ 42.9% src/test/isolation/expected/ 42.2% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..05ec753f4f0 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + } result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..e6cae59d2c9 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,36 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..e0140826e52 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,43 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members +# entries. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit + +# ALTER ROLE ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --cOHldBwZEf1OqVbN-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 20 +++- .../expected/role-membership-drop-member.out | 97 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 63 ++++++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) 11.5% src/backend/commands/ 49.2% src/test/isolation/expected/ 38.6% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..ab3cfb0b13f 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role with OID %u does not exist", roleid))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..7daf48395ca --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,97 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans +step s1_begin: BEGIN; +step s1_set_role: SET ROLE regress_role_member_cu; +step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER; +step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member_cu: <... completed> +step s1_reset: RESET ROLE; +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..054326ac535 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,63 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; + CREATE ROLE regress_role_group_cu; + CREATE ROLE regress_role_member_cu; + GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; + DROP ROLE IF EXISTS regress_role_member_cu; + DROP ROLE IF EXISTS regress_role_group_cu; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_set_role { SET ROLE regress_role_member_cu; } +step s1_grant_cu { GRANT regress_role_group_cu TO CURRENT_USER; } +step s1_commit { COMMIT; } +step s1_reset { RESET ROLE; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_drop_member_cu { DROP ROLE regress_role_member_cu; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member and concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER and concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member and concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit + +# GRANT role TO CURRENT_USER and concurrent DROP of the current user +permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans -- 2.34.1 --fotX2lJRknAHpfH+-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 21 +++++- .../expected/role-membership-drop-member.out | 75 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 51 +++++++++++++ 4 files changed, 147 insertions(+), 1 deletion(-) 14.9% src/backend/commands/ 48.1% src/test/isolation/expected/ 36.1% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..375fb527af2 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", + get_rolespec_name(rolespec)))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..5b267ea32c2 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,75 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..989fe996249 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,51 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --Dvg/WemKV0I3T/Od-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 20 +++- .../expected/role-membership-drop-member.out | 97 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 63 ++++++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) 11.5% src/backend/commands/ 49.2% src/test/isolation/expected/ 38.6% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..ab3cfb0b13f 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role with OID %u does not exist", roleid))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..7daf48395ca --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,97 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans +step s1_begin: BEGIN; +step s1_set_role: SET ROLE regress_role_member_cu; +step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER; +step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member_cu: <... completed> +step s1_reset: RESET ROLE; +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..054326ac535 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,63 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; + CREATE ROLE regress_role_group_cu; + CREATE ROLE regress_role_member_cu; + GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; + DROP ROLE IF EXISTS regress_role_member_cu; + DROP ROLE IF EXISTS regress_role_group_cu; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_set_role { SET ROLE regress_role_member_cu; } +step s1_grant_cu { GRANT regress_role_group_cu TO CURRENT_USER; } +step s1_commit { COMMIT; } +step s1_reset { RESET ROLE; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_drop_member_cu { DROP ROLE regress_role_member_cu; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member and concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER and concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member and concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit + +# GRANT role TO CURRENT_USER and concurrent DROP of the current user +permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans -- 2.34.1 --fotX2lJRknAHpfH+-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 21 +++++- .../expected/role-membership-drop-member.out | 75 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 51 +++++++++++++ 4 files changed, 147 insertions(+), 1 deletion(-) 14.9% src/backend/commands/ 48.1% src/test/isolation/expected/ 36.1% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..375fb527af2 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", + get_rolespec_name(rolespec)))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..5b267ea32c2 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,75 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..989fe996249 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,51 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --Dvg/WemKV0I3T/Od-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 13 +++++- .../expected/role-membership-drop-member.out | 36 ++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 43 +++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) 13.8% src/backend/commands/ 42.9% src/test/isolation/expected/ 42.2% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..05ec753f4f0 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + } result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..e6cae59d2c9 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,36 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..e0140826e52 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,43 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members +# entries. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit + +# ALTER ROLE ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --cOHldBwZEf1OqVbN-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 13 +++++- .../expected/role-membership-drop-member.out | 36 ++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 43 +++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) 13.8% src/backend/commands/ 42.9% src/test/isolation/expected/ 42.2% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..05ec753f4f0 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + } result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..e6cae59d2c9 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,36 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..e0140826e52 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,43 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members +# entries. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit + +# ALTER ROLE ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --cOHldBwZEf1OqVbN-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 21 +++++- .../expected/role-membership-drop-member.out | 75 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 51 +++++++++++++ 4 files changed, 147 insertions(+), 1 deletion(-) 14.9% src/backend/commands/ 48.1% src/test/isolation/expected/ 36.1% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..375fb527af2 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", + get_rolespec_name(rolespec)))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..5b267ea32c2 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,75 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..989fe996249 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,51 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --Dvg/WemKV0I3T/Od-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 13 +++++- .../expected/role-membership-drop-member.out | 36 ++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 43 +++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) 13.8% src/backend/commands/ 42.9% src/test/isolation/expected/ 42.2% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..05ec753f4f0 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + } result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..e6cae59d2c9 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,36 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..e0140826e52 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,43 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members +# entries. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit + +# ALTER ROLE ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --cOHldBwZEf1OqVbN-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 13 +++++- .../expected/role-membership-drop-member.out | 36 ++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 43 +++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) 13.8% src/backend/commands/ 42.9% src/test/isolation/expected/ 42.2% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..05ec753f4f0 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + } result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..e6cae59d2c9 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,36 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..e0140826e52 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,43 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members +# entries. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit + +# ALTER ROLE ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --cOHldBwZEf1OqVbN-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 20 +++- .../expected/role-membership-drop-member.out | 97 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 63 ++++++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) 11.5% src/backend/commands/ 49.2% src/test/isolation/expected/ 38.6% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..ab3cfb0b13f 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role with OID %u does not exist", roleid))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..7daf48395ca --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,97 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans +step s1_begin: BEGIN; +step s1_set_role: SET ROLE regress_role_member_cu; +step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER; +step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member_cu: <... completed> +step s1_reset: RESET ROLE; +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..054326ac535 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,63 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; + CREATE ROLE regress_role_group_cu; + CREATE ROLE regress_role_member_cu; + GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; + DROP ROLE IF EXISTS regress_role_member_cu; + DROP ROLE IF EXISTS regress_role_group_cu; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_set_role { SET ROLE regress_role_member_cu; } +step s1_grant_cu { GRANT regress_role_group_cu TO CURRENT_USER; } +step s1_commit { COMMIT; } +step s1_reset { RESET ROLE; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_drop_member_cu { DROP ROLE regress_role_member_cu; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member and concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER and concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member and concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit + +# GRANT role TO CURRENT_USER and concurrent DROP of the current user +permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans -- 2.34.1 --fotX2lJRknAHpfH+-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 13 +++++- .../expected/role-membership-drop-member.out | 36 ++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 43 +++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) 13.8% src/backend/commands/ 42.9% src/test/isolation/expected/ 42.2% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..05ec753f4f0 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + } result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..e6cae59d2c9 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,36 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..e0140826e52 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,43 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members +# entries. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit + +# ALTER ROLE ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --cOHldBwZEf1OqVbN-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 20 +++- .../expected/role-membership-drop-member.out | 97 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 63 ++++++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) 11.5% src/backend/commands/ 49.2% src/test/isolation/expected/ 38.6% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..ab3cfb0b13f 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role with OID %u does not exist", roleid))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..7daf48395ca --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,97 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans +step s1_begin: BEGIN; +step s1_set_role: SET ROLE regress_role_member_cu; +step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER; +step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member_cu: <... completed> +step s1_reset: RESET ROLE; +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..054326ac535 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,63 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; + CREATE ROLE regress_role_group_cu; + CREATE ROLE regress_role_member_cu; + GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; + DROP ROLE IF EXISTS regress_role_member_cu; + DROP ROLE IF EXISTS regress_role_group_cu; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_set_role { SET ROLE regress_role_member_cu; } +step s1_grant_cu { GRANT regress_role_group_cu TO CURRENT_USER; } +step s1_commit { COMMIT; } +step s1_reset { RESET ROLE; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_drop_member_cu { DROP ROLE regress_role_member_cu; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member and concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER and concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member and concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit + +# GRANT role TO CURRENT_USER and concurrent DROP of the current user +permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans -- 2.34.1 --fotX2lJRknAHpfH+-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 20 +++- .../expected/role-membership-drop-member.out | 97 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 63 ++++++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) 11.5% src/backend/commands/ 49.2% src/test/isolation/expected/ 38.6% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..ab3cfb0b13f 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role with OID %u does not exist", roleid))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..7daf48395ca --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,97 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans +step s1_begin: BEGIN; +step s1_set_role: SET ROLE regress_role_member_cu; +step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER; +step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member_cu: <... completed> +step s1_reset: RESET ROLE; +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..054326ac535 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,63 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; + CREATE ROLE regress_role_group_cu; + CREATE ROLE regress_role_member_cu; + GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; + DROP ROLE IF EXISTS regress_role_member_cu; + DROP ROLE IF EXISTS regress_role_group_cu; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_set_role { SET ROLE regress_role_member_cu; } +step s1_grant_cu { GRANT regress_role_group_cu TO CURRENT_USER; } +step s1_commit { COMMIT; } +step s1_reset { RESET ROLE; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_drop_member_cu { DROP ROLE regress_role_member_cu; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member and concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER and concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member and concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit + +# GRANT role TO CURRENT_USER and concurrent DROP of the current user +permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans -- 2.34.1 --fotX2lJRknAHpfH+-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 21 +++++- .../expected/role-membership-drop-member.out | 75 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 51 +++++++++++++ 4 files changed, 147 insertions(+), 1 deletion(-) 14.9% src/backend/commands/ 48.1% src/test/isolation/expected/ 36.1% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..375fb527af2 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", + get_rolespec_name(rolespec)))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..5b267ea32c2 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,75 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..989fe996249 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,51 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --Dvg/WemKV0I3T/Od-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 20 +++- .../expected/role-membership-drop-member.out | 97 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 63 ++++++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) 11.5% src/backend/commands/ 49.2% src/test/isolation/expected/ 38.6% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..ab3cfb0b13f 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role with OID %u does not exist", roleid))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..7daf48395ca --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,97 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans +step s1_begin: BEGIN; +step s1_set_role: SET ROLE regress_role_member_cu; +step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER; +step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member_cu: <... completed> +step s1_reset: RESET ROLE; +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..054326ac535 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,63 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; + CREATE ROLE regress_role_group_cu; + CREATE ROLE regress_role_member_cu; + GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; + DROP ROLE IF EXISTS regress_role_member_cu; + DROP ROLE IF EXISTS regress_role_group_cu; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_set_role { SET ROLE regress_role_member_cu; } +step s1_grant_cu { GRANT regress_role_group_cu TO CURRENT_USER; } +step s1_commit { COMMIT; } +step s1_reset { RESET ROLE; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_drop_member_cu { DROP ROLE regress_role_member_cu; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member and concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER and concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member and concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit + +# GRANT role TO CURRENT_USER and concurrent DROP of the current user +permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans -- 2.34.1 --fotX2lJRknAHpfH+-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 20 +++- .../expected/role-membership-drop-member.out | 97 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 63 ++++++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) 11.5% src/backend/commands/ 49.2% src/test/isolation/expected/ 38.6% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..ab3cfb0b13f 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role with OID %u does not exist", roleid))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..7daf48395ca --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,97 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans +step s1_begin: BEGIN; +step s1_set_role: SET ROLE regress_role_member_cu; +step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER; +step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member_cu: <... completed> +step s1_reset: RESET ROLE; +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..054326ac535 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,63 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; + CREATE ROLE regress_role_group_cu; + CREATE ROLE regress_role_member_cu; + GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; + DROP ROLE IF EXISTS regress_role_member_cu; + DROP ROLE IF EXISTS regress_role_group_cu; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_set_role { SET ROLE regress_role_member_cu; } +step s1_grant_cu { GRANT regress_role_group_cu TO CURRENT_USER; } +step s1_commit { COMMIT; } +step s1_reset { RESET ROLE; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_drop_member_cu { DROP ROLE regress_role_member_cu; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member and concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER and concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member and concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit + +# GRANT role TO CURRENT_USER and concurrent DROP of the current user +permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans -- 2.34.1 --fotX2lJRknAHpfH+-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 21 +++++- .../expected/role-membership-drop-member.out | 75 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 51 +++++++++++++ 4 files changed, 147 insertions(+), 1 deletion(-) 14.9% src/backend/commands/ 48.1% src/test/isolation/expected/ 36.1% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..375fb527af2 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", + get_rolespec_name(rolespec)))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..5b267ea32c2 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,75 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..989fe996249 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,51 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --Dvg/WemKV0I3T/Od-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 20 +++- .../expected/role-membership-drop-member.out | 97 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 63 ++++++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) 11.5% src/backend/commands/ 49.2% src/test/isolation/expected/ 38.6% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..ab3cfb0b13f 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role with OID %u does not exist", roleid))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..7daf48395ca --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,97 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans +step s1_begin: BEGIN; +step s1_set_role: SET ROLE regress_role_member_cu; +step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER; +step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member_cu: <... completed> +step s1_reset: RESET ROLE; +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..054326ac535 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,63 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; + CREATE ROLE regress_role_group_cu; + CREATE ROLE regress_role_member_cu; + GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; + DROP ROLE IF EXISTS regress_role_member_cu; + DROP ROLE IF EXISTS regress_role_group_cu; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_set_role { SET ROLE regress_role_member_cu; } +step s1_grant_cu { GRANT regress_role_group_cu TO CURRENT_USER; } +step s1_commit { COMMIT; } +step s1_reset { RESET ROLE; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_drop_member_cu { DROP ROLE regress_role_member_cu; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member and concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER and concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member and concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit + +# GRANT role TO CURRENT_USER and concurrent DROP of the current user +permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans -- 2.34.1 --fotX2lJRknAHpfH+-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 20 +++- .../expected/role-membership-drop-member.out | 97 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 63 ++++++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) 11.5% src/backend/commands/ 49.2% src/test/isolation/expected/ 38.6% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..ab3cfb0b13f 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role with OID %u does not exist", roleid))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..7daf48395ca --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,97 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans +step s1_begin: BEGIN; +step s1_set_role: SET ROLE regress_role_member_cu; +step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER; +step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member_cu: <... completed> +step s1_reset: RESET ROLE; +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..054326ac535 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,63 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; + CREATE ROLE regress_role_group_cu; + CREATE ROLE regress_role_member_cu; + GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; + DROP ROLE IF EXISTS regress_role_member_cu; + DROP ROLE IF EXISTS regress_role_group_cu; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_set_role { SET ROLE regress_role_member_cu; } +step s1_grant_cu { GRANT regress_role_group_cu TO CURRENT_USER; } +step s1_commit { COMMIT; } +step s1_reset { RESET ROLE; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_drop_member_cu { DROP ROLE regress_role_member_cu; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member and concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER and concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member and concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit + +# GRANT role TO CURRENT_USER and concurrent DROP of the current user +permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans -- 2.34.1 --fotX2lJRknAHpfH+-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 20 +++- .../expected/role-membership-drop-member.out | 97 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 63 ++++++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) 11.5% src/backend/commands/ 49.2% src/test/isolation/expected/ 38.6% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..ab3cfb0b13f 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role with OID %u does not exist", roleid))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..7daf48395ca --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,97 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans +step s1_begin: BEGIN; +step s1_set_role: SET ROLE regress_role_member_cu; +step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER; +step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member_cu: <... completed> +step s1_reset: RESET ROLE; +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..054326ac535 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,63 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; + CREATE ROLE regress_role_group_cu; + CREATE ROLE regress_role_member_cu; + GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; + DROP ROLE IF EXISTS regress_role_member_cu; + DROP ROLE IF EXISTS regress_role_group_cu; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_set_role { SET ROLE regress_role_member_cu; } +step s1_grant_cu { GRANT regress_role_group_cu TO CURRENT_USER; } +step s1_commit { COMMIT; } +step s1_reset { RESET ROLE; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_drop_member_cu { DROP ROLE regress_role_member_cu; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member and concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER and concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member and concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit + +# GRANT role TO CURRENT_USER and concurrent DROP of the current user +permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans -- 2.34.1 --fotX2lJRknAHpfH+-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 21 +++++- .../expected/role-membership-drop-member.out | 75 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 51 +++++++++++++ 4 files changed, 147 insertions(+), 1 deletion(-) 14.9% src/backend/commands/ 48.1% src/test/isolation/expected/ 36.1% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..375fb527af2 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", + get_rolespec_name(rolespec)))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..5b267ea32c2 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,75 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..989fe996249 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,51 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --Dvg/WemKV0I3T/Od-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 13 +++++- .../expected/role-membership-drop-member.out | 36 ++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 43 +++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) 13.8% src/backend/commands/ 42.9% src/test/isolation/expected/ 42.2% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..05ec753f4f0 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + } result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..e6cae59d2c9 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,36 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..e0140826e52 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,43 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members +# entries. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit + +# ALTER ROLE ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --cOHldBwZEf1OqVbN-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 13 +++++- .../expected/role-membership-drop-member.out | 36 ++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 43 +++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) 13.8% src/backend/commands/ 42.9% src/test/isolation/expected/ 42.2% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..05ec753f4f0 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + } result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..e6cae59d2c9 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,36 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..e0140826e52 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,43 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members +# entries. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit + +# ALTER ROLE ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --cOHldBwZEf1OqVbN-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 13 +++++- .../expected/role-membership-drop-member.out | 36 ++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 43 +++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) 13.8% src/backend/commands/ 42.9% src/test/isolation/expected/ 42.2% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..05ec753f4f0 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + } result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..e6cae59d2c9 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,36 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..e0140826e52 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,43 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members +# entries. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit + +# ALTER ROLE ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --cOHldBwZEf1OqVbN-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 13 +++++- .../expected/role-membership-drop-member.out | 36 ++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 43 +++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) 13.8% src/backend/commands/ 42.9% src/test/isolation/expected/ 42.2% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..05ec753f4f0 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + } result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..e6cae59d2c9 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,36 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..e0140826e52 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,43 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members +# entries. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit + +# ALTER ROLE ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --cOHldBwZEf1OqVbN-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 13 +++++- .../expected/role-membership-drop-member.out | 36 ++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 43 +++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) 13.8% src/backend/commands/ 42.9% src/test/isolation/expected/ 42.2% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..05ec753f4f0 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + } result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..e6cae59d2c9 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,36 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..e0140826e52 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,43 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members +# entries. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit + +# ALTER ROLE ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --cOHldBwZEf1OqVbN-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 21 +++++- .../expected/role-membership-drop-member.out | 75 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 51 +++++++++++++ 4 files changed, 147 insertions(+), 1 deletion(-) 14.9% src/backend/commands/ 48.1% src/test/isolation/expected/ 36.1% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..375fb527af2 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", + get_rolespec_name(rolespec)))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..5b267ea32c2 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,75 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..989fe996249 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,51 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --Dvg/WemKV0I3T/Od-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 13 +++++- .../expected/role-membership-drop-member.out | 36 ++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 43 +++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) 13.8% src/backend/commands/ 42.9% src/test/isolation/expected/ 42.2% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..05ec753f4f0 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + } result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..e6cae59d2c9 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,36 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..e0140826e52 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,43 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members +# entries. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit + +# ALTER ROLE ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --cOHldBwZEf1OqVbN-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 13 +++++- .../expected/role-membership-drop-member.out | 36 ++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 43 +++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) 13.8% src/backend/commands/ 42.9% src/test/isolation/expected/ 42.2% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..05ec753f4f0 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + } result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..e6cae59d2c9 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,36 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..e0140826e52 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,43 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members +# entries. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit + +# ALTER ROLE ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --cOHldBwZEf1OqVbN-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 13 +++++- .../expected/role-membership-drop-member.out | 36 ++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 43 +++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) 13.8% src/backend/commands/ 42.9% src/test/isolation/expected/ 42.2% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..05ec753f4f0 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + } result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..e6cae59d2c9 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,36 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..e0140826e52 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,43 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members +# entries. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit + +# ALTER ROLE ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --cOHldBwZEf1OqVbN-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 21 +++++- .../expected/role-membership-drop-member.out | 75 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 51 +++++++++++++ 4 files changed, 147 insertions(+), 1 deletion(-) 14.9% src/backend/commands/ 48.1% src/test/isolation/expected/ 36.1% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..375fb527af2 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", + get_rolespec_name(rolespec)))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..5b267ea32c2 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,75 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..989fe996249 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,51 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --Dvg/WemKV0I3T/Od-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 20 +++- .../expected/role-membership-drop-member.out | 97 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 63 ++++++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) 11.5% src/backend/commands/ 49.2% src/test/isolation/expected/ 38.6% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..ab3cfb0b13f 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role with OID %u does not exist", roleid))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..7daf48395ca --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,97 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans +step s1_begin: BEGIN; +step s1_set_role: SET ROLE regress_role_member_cu; +step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER; +step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member_cu: <... completed> +step s1_reset: RESET ROLE; +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..054326ac535 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,63 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; + CREATE ROLE regress_role_group_cu; + CREATE ROLE regress_role_member_cu; + GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; + DROP ROLE IF EXISTS regress_role_member_cu; + DROP ROLE IF EXISTS regress_role_group_cu; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_set_role { SET ROLE regress_role_member_cu; } +step s1_grant_cu { GRANT regress_role_group_cu TO CURRENT_USER; } +step s1_commit { COMMIT; } +step s1_reset { RESET ROLE; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_drop_member_cu { DROP ROLE regress_role_member_cu; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member and concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER and concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member and concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit + +# GRANT role TO CURRENT_USER and concurrent DROP of the current user +permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans -- 2.34.1 --fotX2lJRknAHpfH+-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 21 +++++- .../expected/role-membership-drop-member.out | 75 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 51 +++++++++++++ 4 files changed, 147 insertions(+), 1 deletion(-) 14.9% src/backend/commands/ 48.1% src/test/isolation/expected/ 36.1% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..375fb527af2 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", + get_rolespec_name(rolespec)))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..5b267ea32c2 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,75 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..989fe996249 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,51 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --Dvg/WemKV0I3T/Od-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 21 +++++- .../expected/role-membership-drop-member.out | 75 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 51 +++++++++++++ 4 files changed, 147 insertions(+), 1 deletion(-) 14.9% src/backend/commands/ 48.1% src/test/isolation/expected/ 36.1% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..375fb527af2 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", + get_rolespec_name(rolespec)))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..5b267ea32c2 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,75 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..989fe996249 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,51 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --Dvg/WemKV0I3T/Od-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 21 +++++- .../expected/role-membership-drop-member.out | 75 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 51 +++++++++++++ 4 files changed, 147 insertions(+), 1 deletion(-) 14.9% src/backend/commands/ 48.1% src/test/isolation/expected/ 36.1% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..375fb527af2 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", + get_rolespec_name(rolespec)))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..5b267ea32c2 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,75 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..989fe996249 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,51 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --Dvg/WemKV0I3T/Od-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 13 +++++- .../expected/role-membership-drop-member.out | 36 ++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 43 +++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) 13.8% src/backend/commands/ 42.9% src/test/isolation/expected/ 42.2% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..05ec753f4f0 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + } result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..e6cae59d2c9 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,36 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..e0140826e52 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,43 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members +# entries. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit + +# ALTER ROLE ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --cOHldBwZEf1OqVbN-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 13 +++++- .../expected/role-membership-drop-member.out | 36 ++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 43 +++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) 13.8% src/backend/commands/ 42.9% src/test/isolation/expected/ 42.2% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..05ec753f4f0 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + } result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..e6cae59d2c9 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,36 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..e0140826e52 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,43 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members +# entries. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit + +# ALTER ROLE ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --cOHldBwZEf1OqVbN-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 21 +++++- .../expected/role-membership-drop-member.out | 75 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 51 +++++++++++++ 4 files changed, 147 insertions(+), 1 deletion(-) 14.9% src/backend/commands/ 48.1% src/test/isolation/expected/ 36.1% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..375fb527af2 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", + get_rolespec_name(rolespec)))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..5b267ea32c2 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,75 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..989fe996249 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,51 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --Dvg/WemKV0I3T/Od-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 13 +++++- .../expected/role-membership-drop-member.out | 36 ++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 43 +++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) 13.8% src/backend/commands/ 42.9% src/test/isolation/expected/ 42.2% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..05ec753f4f0 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + } result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..e6cae59d2c9 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,36 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..e0140826e52 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,43 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members +# entries. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit + +# ALTER ROLE ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --cOHldBwZEf1OqVbN-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 21 +++++- .../expected/role-membership-drop-member.out | 75 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 51 +++++++++++++ 4 files changed, 147 insertions(+), 1 deletion(-) 14.9% src/backend/commands/ 48.1% src/test/isolation/expected/ 36.1% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..375fb527af2 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", + get_rolespec_name(rolespec)))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..5b267ea32c2 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,75 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..989fe996249 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,51 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --Dvg/WemKV0I3T/Od-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 20 +++- .../expected/role-membership-drop-member.out | 97 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 63 ++++++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) 11.5% src/backend/commands/ 49.2% src/test/isolation/expected/ 38.6% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..ab3cfb0b13f 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role with OID %u does not exist", roleid))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..7daf48395ca --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,97 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans +step s1_begin: BEGIN; +step s1_set_role: SET ROLE regress_role_member_cu; +step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER; +step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member_cu: <... completed> +step s1_reset: RESET ROLE; +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..054326ac535 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,63 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; + CREATE ROLE regress_role_group_cu; + CREATE ROLE regress_role_member_cu; + GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; + DROP ROLE IF EXISTS regress_role_member_cu; + DROP ROLE IF EXISTS regress_role_group_cu; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_set_role { SET ROLE regress_role_member_cu; } +step s1_grant_cu { GRANT regress_role_group_cu TO CURRENT_USER; } +step s1_commit { COMMIT; } +step s1_reset { RESET ROLE; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_drop_member_cu { DROP ROLE regress_role_member_cu; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member and concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER and concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member and concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit + +# GRANT role TO CURRENT_USER and concurrent DROP of the current user +permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans -- 2.34.1 --fotX2lJRknAHpfH+-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 21 +++++- .../expected/role-membership-drop-member.out | 75 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 51 +++++++++++++ 4 files changed, 147 insertions(+), 1 deletion(-) 14.9% src/backend/commands/ 48.1% src/test/isolation/expected/ 36.1% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..375fb527af2 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", + get_rolespec_name(rolespec)))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..5b267ea32c2 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,75 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..989fe996249 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,51 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --Dvg/WemKV0I3T/Od-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 13 +++++- .../expected/role-membership-drop-member.out | 36 ++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 43 +++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) 13.8% src/backend/commands/ 42.9% src/test/isolation/expected/ 42.2% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..05ec753f4f0 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + } result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..e6cae59d2c9 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,36 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..e0140826e52 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,43 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members +# entries. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit + +# ALTER ROLE ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --cOHldBwZEf1OqVbN-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 21 +++++- .../expected/role-membership-drop-member.out | 75 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 51 +++++++++++++ 4 files changed, 147 insertions(+), 1 deletion(-) 14.9% src/backend/commands/ 48.1% src/test/isolation/expected/ 36.1% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..375fb527af2 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", + get_rolespec_name(rolespec)))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..5b267ea32c2 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,75 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..989fe996249 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,51 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --Dvg/WemKV0I3T/Od-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 13 +++++- .../expected/role-membership-drop-member.out | 36 ++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 43 +++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) 13.8% src/backend/commands/ 42.9% src/test/isolation/expected/ 42.2% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..05ec753f4f0 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + } result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..e6cae59d2c9 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,36 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..e0140826e52 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,43 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members +# entries. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit + +# ALTER ROLE ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --cOHldBwZEf1OqVbN-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 13 +++++- .../expected/role-membership-drop-member.out | 36 ++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 43 +++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) 13.8% src/backend/commands/ 42.9% src/test/isolation/expected/ 42.2% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..05ec753f4f0 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + } result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..e6cae59d2c9 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,36 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..e0140826e52 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,43 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members +# entries. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit + +# ALTER ROLE ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --cOHldBwZEf1OqVbN-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 20 +++- .../expected/role-membership-drop-member.out | 97 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 63 ++++++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) 11.5% src/backend/commands/ 49.2% src/test/isolation/expected/ 38.6% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..ab3cfb0b13f 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role with OID %u does not exist", roleid))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..7daf48395ca --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,97 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans +step s1_begin: BEGIN; +step s1_set_role: SET ROLE regress_role_member_cu; +step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER; +step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member_cu: <... completed> +step s1_reset: RESET ROLE; +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..054326ac535 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,63 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; + CREATE ROLE regress_role_group_cu; + CREATE ROLE regress_role_member_cu; + GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; + DROP ROLE IF EXISTS regress_role_member_cu; + DROP ROLE IF EXISTS regress_role_group_cu; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_set_role { SET ROLE regress_role_member_cu; } +step s1_grant_cu { GRANT regress_role_group_cu TO CURRENT_USER; } +step s1_commit { COMMIT; } +step s1_reset { RESET ROLE; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_drop_member_cu { DROP ROLE regress_role_member_cu; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member and concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER and concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member and concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit + +# GRANT role TO CURRENT_USER and concurrent DROP of the current user +permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans -- 2.34.1 --fotX2lJRknAHpfH+-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 21 +++++- .../expected/role-membership-drop-member.out | 75 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 51 +++++++++++++ 4 files changed, 147 insertions(+), 1 deletion(-) 14.9% src/backend/commands/ 48.1% src/test/isolation/expected/ 36.1% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..375fb527af2 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", + get_rolespec_name(rolespec)))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..5b267ea32c2 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,75 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..989fe996249 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,51 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --Dvg/WemKV0I3T/Od-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 20 +++- .../expected/role-membership-drop-member.out | 97 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 63 ++++++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) 11.5% src/backend/commands/ 49.2% src/test/isolation/expected/ 38.6% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..ab3cfb0b13f 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role with OID %u does not exist", roleid))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..7daf48395ca --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,97 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans +step s1_begin: BEGIN; +step s1_set_role: SET ROLE regress_role_member_cu; +step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER; +step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member_cu: <... completed> +step s1_reset: RESET ROLE; +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..054326ac535 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,63 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; + CREATE ROLE regress_role_group_cu; + CREATE ROLE regress_role_member_cu; + GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; + DROP ROLE IF EXISTS regress_role_member_cu; + DROP ROLE IF EXISTS regress_role_group_cu; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_set_role { SET ROLE regress_role_member_cu; } +step s1_grant_cu { GRANT regress_role_group_cu TO CURRENT_USER; } +step s1_commit { COMMIT; } +step s1_reset { RESET ROLE; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_drop_member_cu { DROP ROLE regress_role_member_cu; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member and concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER and concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member and concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit + +# GRANT role TO CURRENT_USER and concurrent DROP of the current user +permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans -- 2.34.1 --fotX2lJRknAHpfH+-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 21 +++++- .../expected/role-membership-drop-member.out | 75 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 51 +++++++++++++ 4 files changed, 147 insertions(+), 1 deletion(-) 14.9% src/backend/commands/ 48.1% src/test/isolation/expected/ 36.1% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..375fb527af2 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", + get_rolespec_name(rolespec)))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..5b267ea32c2 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,75 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..989fe996249 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,51 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --Dvg/WemKV0I3T/Od-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 20 +++- .../expected/role-membership-drop-member.out | 97 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 63 ++++++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) 11.5% src/backend/commands/ 49.2% src/test/isolation/expected/ 38.6% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..ab3cfb0b13f 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role with OID %u does not exist", roleid))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..7daf48395ca --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,97 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans +step s1_begin: BEGIN; +step s1_set_role: SET ROLE regress_role_member_cu; +step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER; +step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member_cu: <... completed> +step s1_reset: RESET ROLE; +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..054326ac535 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,63 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; + CREATE ROLE regress_role_group_cu; + CREATE ROLE regress_role_member_cu; + GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; + DROP ROLE IF EXISTS regress_role_member_cu; + DROP ROLE IF EXISTS regress_role_group_cu; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_set_role { SET ROLE regress_role_member_cu; } +step s1_grant_cu { GRANT regress_role_group_cu TO CURRENT_USER; } +step s1_commit { COMMIT; } +step s1_reset { RESET ROLE; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_drop_member_cu { DROP ROLE regress_role_member_cu; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member and concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER and concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member and concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit + +# GRANT role TO CURRENT_USER and concurrent DROP of the current user +permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans -- 2.34.1 --fotX2lJRknAHpfH+-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 13 +++++- .../expected/role-membership-drop-member.out | 36 ++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 43 +++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) 13.8% src/backend/commands/ 42.9% src/test/isolation/expected/ 42.2% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..05ec753f4f0 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + } result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..e6cae59d2c9 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,36 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..e0140826e52 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,43 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members +# entries. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit + +# ALTER ROLE ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --cOHldBwZEf1OqVbN-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 21 +++++- .../expected/role-membership-drop-member.out | 75 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 51 +++++++++++++ 4 files changed, 147 insertions(+), 1 deletion(-) 14.9% src/backend/commands/ 48.1% src/test/isolation/expected/ 36.1% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..375fb527af2 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", + get_rolespec_name(rolespec)))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..5b267ea32c2 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,75 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..989fe996249 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,51 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --Dvg/WemKV0I3T/Od-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 21 +++++- .../expected/role-membership-drop-member.out | 75 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 51 +++++++++++++ 4 files changed, 147 insertions(+), 1 deletion(-) 14.9% src/backend/commands/ 48.1% src/test/isolation/expected/ 36.1% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..375fb527af2 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", + get_rolespec_name(rolespec)))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..5b267ea32c2 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,75 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..989fe996249 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,51 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --Dvg/WemKV0I3T/Od-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 20 +++- .../expected/role-membership-drop-member.out | 97 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 63 ++++++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) 11.5% src/backend/commands/ 49.2% src/test/isolation/expected/ 38.6% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..ab3cfb0b13f 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role with OID %u does not exist", roleid))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..7daf48395ca --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,97 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans +step s1_begin: BEGIN; +step s1_set_role: SET ROLE regress_role_member_cu; +step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER; +step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member_cu: <... completed> +step s1_reset: RESET ROLE; +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..054326ac535 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,63 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; + CREATE ROLE regress_role_group_cu; + CREATE ROLE regress_role_member_cu; + GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; + DROP ROLE IF EXISTS regress_role_member_cu; + DROP ROLE IF EXISTS regress_role_group_cu; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_set_role { SET ROLE regress_role_member_cu; } +step s1_grant_cu { GRANT regress_role_group_cu TO CURRENT_USER; } +step s1_commit { COMMIT; } +step s1_reset { RESET ROLE; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_drop_member_cu { DROP ROLE regress_role_member_cu; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member and concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER and concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member and concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit + +# GRANT role TO CURRENT_USER and concurrent DROP of the current user +permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans -- 2.34.1 --fotX2lJRknAHpfH+-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 21 +++++- .../expected/role-membership-drop-member.out | 75 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 51 +++++++++++++ 4 files changed, 147 insertions(+), 1 deletion(-) 14.9% src/backend/commands/ 48.1% src/test/isolation/expected/ 36.1% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..375fb527af2 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", + get_rolespec_name(rolespec)))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..5b267ea32c2 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,75 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..989fe996249 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,51 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --Dvg/WemKV0I3T/Od-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 21 +++++- .../expected/role-membership-drop-member.out | 75 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 51 +++++++++++++ 4 files changed, 147 insertions(+), 1 deletion(-) 14.9% src/backend/commands/ 48.1% src/test/isolation/expected/ 36.1% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..375fb527af2 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", + get_rolespec_name(rolespec)))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..5b267ea32c2 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,75 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..989fe996249 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,51 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --Dvg/WemKV0I3T/Od-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 13 +++++- .../expected/role-membership-drop-member.out | 36 ++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 43 +++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) 13.8% src/backend/commands/ 42.9% src/test/isolation/expected/ 42.2% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..05ec753f4f0 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + } result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..e6cae59d2c9 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,36 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..e0140826e52 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,43 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members +# entries. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit + +# ALTER ROLE ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --cOHldBwZEf1OqVbN-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 13 +++++- .../expected/role-membership-drop-member.out | 36 ++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 43 +++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) 13.8% src/backend/commands/ 42.9% src/test/isolation/expected/ 42.2% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..05ec753f4f0 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + } result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..e6cae59d2c9 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,36 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..e0140826e52 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,43 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members +# entries. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit + +# ALTER ROLE ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --cOHldBwZEf1OqVbN-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 21 +++++- .../expected/role-membership-drop-member.out | 75 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 51 +++++++++++++ 4 files changed, 147 insertions(+), 1 deletion(-) 14.9% src/backend/commands/ 48.1% src/test/isolation/expected/ 36.1% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..375fb527af2 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", + get_rolespec_name(rolespec)))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..5b267ea32c2 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,75 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..989fe996249 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,51 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --Dvg/WemKV0I3T/Od-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 21 +++++- .../expected/role-membership-drop-member.out | 75 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 51 +++++++++++++ 4 files changed, 147 insertions(+), 1 deletion(-) 14.9% src/backend/commands/ 48.1% src/test/isolation/expected/ 36.1% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..375fb527af2 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", + get_rolespec_name(rolespec)))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..5b267ea32c2 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,75 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..989fe996249 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,51 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --Dvg/WemKV0I3T/Od-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 21 +++++- .../expected/role-membership-drop-member.out | 75 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 51 +++++++++++++ 4 files changed, 147 insertions(+), 1 deletion(-) 14.9% src/backend/commands/ 48.1% src/test/isolation/expected/ 36.1% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..375fb527af2 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", + get_rolespec_name(rolespec)))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..5b267ea32c2 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,75 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..989fe996249 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,51 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --Dvg/WemKV0I3T/Od-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 13 +++++- .../expected/role-membership-drop-member.out | 36 ++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 43 +++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) 13.8% src/backend/commands/ 42.9% src/test/isolation/expected/ 42.2% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..05ec753f4f0 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + } result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..e6cae59d2c9 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,36 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..e0140826e52 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,43 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members +# entries. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit + +# ALTER ROLE ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --cOHldBwZEf1OqVbN-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 20 +++- .../expected/role-membership-drop-member.out | 97 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 63 ++++++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) 11.5% src/backend/commands/ 49.2% src/test/isolation/expected/ 38.6% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..ab3cfb0b13f 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role with OID %u does not exist", roleid))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..7daf48395ca --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,97 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans +step s1_begin: BEGIN; +step s1_set_role: SET ROLE regress_role_member_cu; +step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER; +step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member_cu: <... completed> +step s1_reset: RESET ROLE; +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..054326ac535 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,63 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; + CREATE ROLE regress_role_group_cu; + CREATE ROLE regress_role_member_cu; + GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; + DROP ROLE IF EXISTS regress_role_member_cu; + DROP ROLE IF EXISTS regress_role_group_cu; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_set_role { SET ROLE regress_role_member_cu; } +step s1_grant_cu { GRANT regress_role_group_cu TO CURRENT_USER; } +step s1_commit { COMMIT; } +step s1_reset { RESET ROLE; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_drop_member_cu { DROP ROLE regress_role_member_cu; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member and concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER and concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member and concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit + +# GRANT role TO CURRENT_USER and concurrent DROP of the current user +permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans -- 2.34.1 --fotX2lJRknAHpfH+-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 21 +++++- .../expected/role-membership-drop-member.out | 75 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 51 +++++++++++++ 4 files changed, 147 insertions(+), 1 deletion(-) 14.9% src/backend/commands/ 48.1% src/test/isolation/expected/ 36.1% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..375fb527af2 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", + get_rolespec_name(rolespec)))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..5b267ea32c2 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,75 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..989fe996249 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,51 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --Dvg/WemKV0I3T/Od-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 20 +++- .../expected/role-membership-drop-member.out | 97 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 63 ++++++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) 11.5% src/backend/commands/ 49.2% src/test/isolation/expected/ 38.6% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..ab3cfb0b13f 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role with OID %u does not exist", roleid))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..7daf48395ca --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,97 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans +step s1_begin: BEGIN; +step s1_set_role: SET ROLE regress_role_member_cu; +step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER; +step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member_cu: <... completed> +step s1_reset: RESET ROLE; +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..054326ac535 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,63 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; + CREATE ROLE regress_role_group_cu; + CREATE ROLE regress_role_member_cu; + GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; + DROP ROLE IF EXISTS regress_role_member_cu; + DROP ROLE IF EXISTS regress_role_group_cu; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_set_role { SET ROLE regress_role_member_cu; } +step s1_grant_cu { GRANT regress_role_group_cu TO CURRENT_USER; } +step s1_commit { COMMIT; } +step s1_reset { RESET ROLE; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_drop_member_cu { DROP ROLE regress_role_member_cu; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member and concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER and concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member and concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit + +# GRANT role TO CURRENT_USER and concurrent DROP of the current user +permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans -- 2.34.1 --fotX2lJRknAHpfH+-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 21 +++++- .../expected/role-membership-drop-member.out | 75 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 51 +++++++++++++ 4 files changed, 147 insertions(+), 1 deletion(-) 14.9% src/backend/commands/ 48.1% src/test/isolation/expected/ 36.1% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..375fb527af2 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", + get_rolespec_name(rolespec)))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..5b267ea32c2 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,75 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..989fe996249 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,51 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --Dvg/WemKV0I3T/Od-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 21 +++++- .../expected/role-membership-drop-member.out | 75 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 51 +++++++++++++ 4 files changed, 147 insertions(+), 1 deletion(-) 14.9% src/backend/commands/ 48.1% src/test/isolation/expected/ 36.1% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..375fb527af2 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", + get_rolespec_name(rolespec)))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..5b267ea32c2 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,75 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..989fe996249 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,51 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --Dvg/WemKV0I3T/Od-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 13 +++++- .../expected/role-membership-drop-member.out | 36 ++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 43 +++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) 13.8% src/backend/commands/ 42.9% src/test/isolation/expected/ 42.2% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..05ec753f4f0 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + } result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..e6cae59d2c9 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,36 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..e0140826e52 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,43 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members +# entries. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit + +# ALTER ROLE ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --cOHldBwZEf1OqVbN-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 13 +++++- .../expected/role-membership-drop-member.out | 36 ++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 43 +++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) 13.8% src/backend/commands/ 42.9% src/test/isolation/expected/ 42.2% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..05ec753f4f0 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + } result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..e6cae59d2c9 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,36 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..e0140826e52 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,43 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members +# entries. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit + +# ALTER ROLE ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --cOHldBwZEf1OqVbN-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 13 +++++- .../expected/role-membership-drop-member.out | 36 ++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 43 +++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) 13.8% src/backend/commands/ 42.9% src/test/isolation/expected/ 42.2% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..05ec753f4f0 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + } result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..e6cae59d2c9 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,36 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..e0140826e52 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,43 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members +# entries. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit + +# ALTER ROLE ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --cOHldBwZEf1OqVbN-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 13 +++++- .../expected/role-membership-drop-member.out | 36 ++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 43 +++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) 13.8% src/backend/commands/ 42.9% src/test/isolation/expected/ 42.2% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..05ec753f4f0 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + } result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..e6cae59d2c9 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,36 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..e0140826e52 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,43 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members +# entries. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit + +# ALTER ROLE ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --cOHldBwZEf1OqVbN-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 21 +++++- .../expected/role-membership-drop-member.out | 75 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 51 +++++++++++++ 4 files changed, 147 insertions(+), 1 deletion(-) 14.9% src/backend/commands/ 48.1% src/test/isolation/expected/ 36.1% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..375fb527af2 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", + get_rolespec_name(rolespec)))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..5b267ea32c2 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,75 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..989fe996249 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,51 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --Dvg/WemKV0I3T/Od-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 13 +++++- .../expected/role-membership-drop-member.out | 36 ++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 43 +++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) 13.8% src/backend/commands/ 42.9% src/test/isolation/expected/ 42.2% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..05ec753f4f0 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + } result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..e6cae59d2c9 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,36 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..e0140826e52 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,43 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members +# entries. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit + +# ALTER ROLE ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --cOHldBwZEf1OqVbN-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 20 +++- .../expected/role-membership-drop-member.out | 97 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 63 ++++++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) 11.5% src/backend/commands/ 49.2% src/test/isolation/expected/ 38.6% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..ab3cfb0b13f 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role with OID %u does not exist", roleid))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..7daf48395ca --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,97 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans +step s1_begin: BEGIN; +step s1_set_role: SET ROLE regress_role_member_cu; +step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER; +step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member_cu: <... completed> +step s1_reset: RESET ROLE; +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..054326ac535 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,63 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; + CREATE ROLE regress_role_group_cu; + CREATE ROLE regress_role_member_cu; + GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; + DROP ROLE IF EXISTS regress_role_member_cu; + DROP ROLE IF EXISTS regress_role_group_cu; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_set_role { SET ROLE regress_role_member_cu; } +step s1_grant_cu { GRANT regress_role_group_cu TO CURRENT_USER; } +step s1_commit { COMMIT; } +step s1_reset { RESET ROLE; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_drop_member_cu { DROP ROLE regress_role_member_cu; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member and concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER and concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member and concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit + +# GRANT role TO CURRENT_USER and concurrent DROP of the current user +permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans -- 2.34.1 --fotX2lJRknAHpfH+-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 20 +++- .../expected/role-membership-drop-member.out | 97 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 63 ++++++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) 11.5% src/backend/commands/ 49.2% src/test/isolation/expected/ 38.6% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..ab3cfb0b13f 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role with OID %u does not exist", roleid))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..7daf48395ca --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,97 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans +step s1_begin: BEGIN; +step s1_set_role: SET ROLE regress_role_member_cu; +step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER; +step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member_cu: <... completed> +step s1_reset: RESET ROLE; +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..054326ac535 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,63 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; + CREATE ROLE regress_role_group_cu; + CREATE ROLE regress_role_member_cu; + GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; + DROP ROLE IF EXISTS regress_role_member_cu; + DROP ROLE IF EXISTS regress_role_group_cu; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_set_role { SET ROLE regress_role_member_cu; } +step s1_grant_cu { GRANT regress_role_group_cu TO CURRENT_USER; } +step s1_commit { COMMIT; } +step s1_reset { RESET ROLE; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_drop_member_cu { DROP ROLE regress_role_member_cu; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member and concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER and concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member and concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit + +# GRANT role TO CURRENT_USER and concurrent DROP of the current user +permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans -- 2.34.1 --fotX2lJRknAHpfH+-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 13 +++++- .../expected/role-membership-drop-member.out | 36 ++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 43 +++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) 13.8% src/backend/commands/ 42.9% src/test/isolation/expected/ 42.2% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..05ec753f4f0 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + } result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..e6cae59d2c9 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,36 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..e0140826e52 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,43 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members +# entries. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit + +# ALTER ROLE ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --cOHldBwZEf1OqVbN-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 13 +++++- .../expected/role-membership-drop-member.out | 36 ++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 43 +++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) 13.8% src/backend/commands/ 42.9% src/test/isolation/expected/ 42.2% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..05ec753f4f0 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + } result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..e6cae59d2c9 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,36 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..e0140826e52 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,43 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members +# entries. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit + +# ALTER ROLE ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --cOHldBwZEf1OqVbN-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 20 +++- .../expected/role-membership-drop-member.out | 97 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 63 ++++++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) 11.5% src/backend/commands/ 49.2% src/test/isolation/expected/ 38.6% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..ab3cfb0b13f 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role with OID %u does not exist", roleid))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..7daf48395ca --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,97 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans +step s1_begin: BEGIN; +step s1_set_role: SET ROLE regress_role_member_cu; +step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER; +step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member_cu: <... completed> +step s1_reset: RESET ROLE; +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..054326ac535 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,63 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; + CREATE ROLE regress_role_group_cu; + CREATE ROLE regress_role_member_cu; + GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; + DROP ROLE IF EXISTS regress_role_member_cu; + DROP ROLE IF EXISTS regress_role_group_cu; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_set_role { SET ROLE regress_role_member_cu; } +step s1_grant_cu { GRANT regress_role_group_cu TO CURRENT_USER; } +step s1_commit { COMMIT; } +step s1_reset { RESET ROLE; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_drop_member_cu { DROP ROLE regress_role_member_cu; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member and concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER and concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member and concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit + +# GRANT role TO CURRENT_USER and concurrent DROP of the current user +permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans -- 2.34.1 --fotX2lJRknAHpfH+-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 20 +++- .../expected/role-membership-drop-member.out | 97 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 63 ++++++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) 11.5% src/backend/commands/ 49.2% src/test/isolation/expected/ 38.6% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..ab3cfb0b13f 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role with OID %u does not exist", roleid))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..7daf48395ca --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,97 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans +step s1_begin: BEGIN; +step s1_set_role: SET ROLE regress_role_member_cu; +step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER; +step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member_cu: <... completed> +step s1_reset: RESET ROLE; +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..054326ac535 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,63 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; + CREATE ROLE regress_role_group_cu; + CREATE ROLE regress_role_member_cu; + GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; + DROP ROLE IF EXISTS regress_role_member_cu; + DROP ROLE IF EXISTS regress_role_group_cu; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_set_role { SET ROLE regress_role_member_cu; } +step s1_grant_cu { GRANT regress_role_group_cu TO CURRENT_USER; } +step s1_commit { COMMIT; } +step s1_reset { RESET ROLE; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_drop_member_cu { DROP ROLE regress_role_member_cu; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member and concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER and concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member and concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit + +# GRANT role TO CURRENT_USER and concurrent DROP of the current user +permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans -- 2.34.1 --fotX2lJRknAHpfH+-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 20 +++- .../expected/role-membership-drop-member.out | 97 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 63 ++++++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) 11.5% src/backend/commands/ 49.2% src/test/isolation/expected/ 38.6% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..ab3cfb0b13f 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role with OID %u does not exist", roleid))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..7daf48395ca --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,97 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans +step s1_begin: BEGIN; +step s1_set_role: SET ROLE regress_role_member_cu; +step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER; +step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member_cu: <... completed> +step s1_reset: RESET ROLE; +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..054326ac535 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,63 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; + CREATE ROLE regress_role_group_cu; + CREATE ROLE regress_role_member_cu; + GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; + DROP ROLE IF EXISTS regress_role_member_cu; + DROP ROLE IF EXISTS regress_role_group_cu; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_set_role { SET ROLE regress_role_member_cu; } +step s1_grant_cu { GRANT regress_role_group_cu TO CURRENT_USER; } +step s1_commit { COMMIT; } +step s1_reset { RESET ROLE; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_drop_member_cu { DROP ROLE regress_role_member_cu; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member and concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER and concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member and concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit + +# GRANT role TO CURRENT_USER and concurrent DROP of the current user +permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans -- 2.34.1 --fotX2lJRknAHpfH+-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 20 +++- .../expected/role-membership-drop-member.out | 97 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 63 ++++++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) 11.5% src/backend/commands/ 49.2% src/test/isolation/expected/ 38.6% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..ab3cfb0b13f 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role with OID %u does not exist", roleid))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..7daf48395ca --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,97 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans +step s1_begin: BEGIN; +step s1_set_role: SET ROLE regress_role_member_cu; +step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER; +step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member_cu: <... completed> +step s1_reset: RESET ROLE; +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..054326ac535 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,63 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; + CREATE ROLE regress_role_group_cu; + CREATE ROLE regress_role_member_cu; + GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; + DROP ROLE IF EXISTS regress_role_member_cu; + DROP ROLE IF EXISTS regress_role_group_cu; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_set_role { SET ROLE regress_role_member_cu; } +step s1_grant_cu { GRANT regress_role_group_cu TO CURRENT_USER; } +step s1_commit { COMMIT; } +step s1_reset { RESET ROLE; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_drop_member_cu { DROP ROLE regress_role_member_cu; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member and concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER and concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member and concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit + +# GRANT role TO CURRENT_USER and concurrent DROP of the current user +permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans -- 2.34.1 --fotX2lJRknAHpfH+-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 13 +++++- .../expected/role-membership-drop-member.out | 36 ++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 43 +++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) 13.8% src/backend/commands/ 42.9% src/test/isolation/expected/ 42.2% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..05ec753f4f0 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + } result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..e6cae59d2c9 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,36 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..e0140826e52 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,43 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members +# entries. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit + +# ALTER ROLE ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --cOHldBwZEf1OqVbN-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 21 +++++- .../expected/role-membership-drop-member.out | 75 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 51 +++++++++++++ 4 files changed, 147 insertions(+), 1 deletion(-) 14.9% src/backend/commands/ 48.1% src/test/isolation/expected/ 36.1% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..375fb527af2 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", + get_rolespec_name(rolespec)))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..5b267ea32c2 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,75 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..989fe996249 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,51 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --Dvg/WemKV0I3T/Od-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 20 +++- .../expected/role-membership-drop-member.out | 97 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 63 ++++++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) 11.5% src/backend/commands/ 49.2% src/test/isolation/expected/ 38.6% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..ab3cfb0b13f 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role with OID %u does not exist", roleid))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..7daf48395ca --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,97 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans +step s1_begin: BEGIN; +step s1_set_role: SET ROLE regress_role_member_cu; +step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER; +step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member_cu: <... completed> +step s1_reset: RESET ROLE; +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..054326ac535 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,63 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; + CREATE ROLE regress_role_group_cu; + CREATE ROLE regress_role_member_cu; + GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; + DROP ROLE IF EXISTS regress_role_member_cu; + DROP ROLE IF EXISTS regress_role_group_cu; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_set_role { SET ROLE regress_role_member_cu; } +step s1_grant_cu { GRANT regress_role_group_cu TO CURRENT_USER; } +step s1_commit { COMMIT; } +step s1_reset { RESET ROLE; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_drop_member_cu { DROP ROLE regress_role_member_cu; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member and concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER and concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member and concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit + +# GRANT role TO CURRENT_USER and concurrent DROP of the current user +permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans -- 2.34.1 --fotX2lJRknAHpfH+-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 20 +++- .../expected/role-membership-drop-member.out | 97 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 63 ++++++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) 11.5% src/backend/commands/ 49.2% src/test/isolation/expected/ 38.6% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..ab3cfb0b13f 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role with OID %u does not exist", roleid))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..7daf48395ca --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,97 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans +step s1_begin: BEGIN; +step s1_set_role: SET ROLE regress_role_member_cu; +step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER; +step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member_cu: <... completed> +step s1_reset: RESET ROLE; +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..054326ac535 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,63 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; + CREATE ROLE regress_role_group_cu; + CREATE ROLE regress_role_member_cu; + GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; + DROP ROLE IF EXISTS regress_role_member_cu; + DROP ROLE IF EXISTS regress_role_group_cu; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_set_role { SET ROLE regress_role_member_cu; } +step s1_grant_cu { GRANT regress_role_group_cu TO CURRENT_USER; } +step s1_commit { COMMIT; } +step s1_reset { RESET ROLE; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_drop_member_cu { DROP ROLE regress_role_member_cu; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member and concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER and concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member and concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit + +# GRANT role TO CURRENT_USER and concurrent DROP of the current user +permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans -- 2.34.1 --fotX2lJRknAHpfH+-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 20 +++- .../expected/role-membership-drop-member.out | 97 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 63 ++++++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) 11.5% src/backend/commands/ 49.2% src/test/isolation/expected/ 38.6% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..ab3cfb0b13f 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role with OID %u does not exist", roleid))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..7daf48395ca --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,97 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans +step s1_begin: BEGIN; +step s1_set_role: SET ROLE regress_role_member_cu; +step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER; +step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member_cu: <... completed> +step s1_reset: RESET ROLE; +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..054326ac535 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,63 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; + CREATE ROLE regress_role_group_cu; + CREATE ROLE regress_role_member_cu; + GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; + DROP ROLE IF EXISTS regress_role_member_cu; + DROP ROLE IF EXISTS regress_role_group_cu; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_set_role { SET ROLE regress_role_member_cu; } +step s1_grant_cu { GRANT regress_role_group_cu TO CURRENT_USER; } +step s1_commit { COMMIT; } +step s1_reset { RESET ROLE; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_drop_member_cu { DROP ROLE regress_role_member_cu; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member and concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER and concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member and concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit + +# GRANT role TO CURRENT_USER and concurrent DROP of the current user +permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans -- 2.34.1 --fotX2lJRknAHpfH+-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 20 +++- .../expected/role-membership-drop-member.out | 97 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 63 ++++++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) 11.5% src/backend/commands/ 49.2% src/test/isolation/expected/ 38.6% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..ab3cfb0b13f 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role with OID %u does not exist", roleid))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..7daf48395ca --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,97 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans +step s1_begin: BEGIN; +step s1_set_role: SET ROLE regress_role_member_cu; +step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER; +step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member_cu: <... completed> +step s1_reset: RESET ROLE; +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..054326ac535 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,63 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; + CREATE ROLE regress_role_group_cu; + CREATE ROLE regress_role_member_cu; + GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; + DROP ROLE IF EXISTS regress_role_member_cu; + DROP ROLE IF EXISTS regress_role_group_cu; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_set_role { SET ROLE regress_role_member_cu; } +step s1_grant_cu { GRANT regress_role_group_cu TO CURRENT_USER; } +step s1_commit { COMMIT; } +step s1_reset { RESET ROLE; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_drop_member_cu { DROP ROLE regress_role_member_cu; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member and concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER and concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member and concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit + +# GRANT role TO CURRENT_USER and concurrent DROP of the current user +permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans -- 2.34.1 --fotX2lJRknAHpfH+-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 21 +++++- .../expected/role-membership-drop-member.out | 75 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 51 +++++++++++++ 4 files changed, 147 insertions(+), 1 deletion(-) 14.9% src/backend/commands/ 48.1% src/test/isolation/expected/ 36.1% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..375fb527af2 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", + get_rolespec_name(rolespec)))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..5b267ea32c2 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,75 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..989fe996249 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,51 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --Dvg/WemKV0I3T/Od-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 13 +++++- .../expected/role-membership-drop-member.out | 36 ++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 43 +++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) 13.8% src/backend/commands/ 42.9% src/test/isolation/expected/ 42.2% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..05ec753f4f0 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + } result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..e6cae59d2c9 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,36 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..e0140826e52 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,43 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members +# entries. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit + +# ALTER ROLE ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --cOHldBwZEf1OqVbN-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 20 +++- .../expected/role-membership-drop-member.out | 97 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 63 ++++++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) 11.5% src/backend/commands/ 49.2% src/test/isolation/expected/ 38.6% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..ab3cfb0b13f 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role with OID %u does not exist", roleid))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..7daf48395ca --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,97 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans +step s1_begin: BEGIN; +step s1_set_role: SET ROLE regress_role_member_cu; +step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER; +step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member_cu: <... completed> +step s1_reset: RESET ROLE; +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..054326ac535 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,63 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; + CREATE ROLE regress_role_group_cu; + CREATE ROLE regress_role_member_cu; + GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; + DROP ROLE IF EXISTS regress_role_member_cu; + DROP ROLE IF EXISTS regress_role_group_cu; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_set_role { SET ROLE regress_role_member_cu; } +step s1_grant_cu { GRANT regress_role_group_cu TO CURRENT_USER; } +step s1_commit { COMMIT; } +step s1_reset { RESET ROLE; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_drop_member_cu { DROP ROLE regress_role_member_cu; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member and concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER and concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member and concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit + +# GRANT role TO CURRENT_USER and concurrent DROP of the current user +permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans -- 2.34.1 --fotX2lJRknAHpfH+-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 21 +++++- .../expected/role-membership-drop-member.out | 75 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 51 +++++++++++++ 4 files changed, 147 insertions(+), 1 deletion(-) 14.9% src/backend/commands/ 48.1% src/test/isolation/expected/ 36.1% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..375fb527af2 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", + get_rolespec_name(rolespec)))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..5b267ea32c2 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,75 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..989fe996249 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,51 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --Dvg/WemKV0I3T/Od-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 21 +++++- .../expected/role-membership-drop-member.out | 75 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 51 +++++++++++++ 4 files changed, 147 insertions(+), 1 deletion(-) 14.9% src/backend/commands/ 48.1% src/test/isolation/expected/ 36.1% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..375fb527af2 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", + get_rolespec_name(rolespec)))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..5b267ea32c2 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,75 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..989fe996249 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,51 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --Dvg/WemKV0I3T/Od-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 21 +++++- .../expected/role-membership-drop-member.out | 75 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 51 +++++++++++++ 4 files changed, 147 insertions(+), 1 deletion(-) 14.9% src/backend/commands/ 48.1% src/test/isolation/expected/ 36.1% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..375fb527af2 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", + get_rolespec_name(rolespec)))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..5b267ea32c2 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,75 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..989fe996249 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,51 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --Dvg/WemKV0I3T/Od-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 20 +++- .../expected/role-membership-drop-member.out | 97 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 63 ++++++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) 11.5% src/backend/commands/ 49.2% src/test/isolation/expected/ 38.6% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..ab3cfb0b13f 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role with OID %u does not exist", roleid))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..7daf48395ca --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,97 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans +step s1_begin: BEGIN; +step s1_set_role: SET ROLE regress_role_member_cu; +step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER; +step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member_cu: <... completed> +step s1_reset: RESET ROLE; +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..054326ac535 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,63 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; + CREATE ROLE regress_role_group_cu; + CREATE ROLE regress_role_member_cu; + GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; + DROP ROLE IF EXISTS regress_role_member_cu; + DROP ROLE IF EXISTS regress_role_group_cu; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_set_role { SET ROLE regress_role_member_cu; } +step s1_grant_cu { GRANT regress_role_group_cu TO CURRENT_USER; } +step s1_commit { COMMIT; } +step s1_reset { RESET ROLE; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_drop_member_cu { DROP ROLE regress_role_member_cu; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member and concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER and concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member and concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit + +# GRANT role TO CURRENT_USER and concurrent DROP of the current user +permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans -- 2.34.1 --fotX2lJRknAHpfH+-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 21 +++++- .../expected/role-membership-drop-member.out | 75 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 51 +++++++++++++ 4 files changed, 147 insertions(+), 1 deletion(-) 14.9% src/backend/commands/ 48.1% src/test/isolation/expected/ 36.1% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..375fb527af2 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", + get_rolespec_name(rolespec)))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..5b267ea32c2 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,75 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..989fe996249 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,51 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --Dvg/WemKV0I3T/Od-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 21 +++++- .../expected/role-membership-drop-member.out | 75 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 51 +++++++++++++ 4 files changed, 147 insertions(+), 1 deletion(-) 14.9% src/backend/commands/ 48.1% src/test/isolation/expected/ 36.1% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..375fb527af2 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", + get_rolespec_name(rolespec)))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..5b267ea32c2 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,75 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..989fe996249 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,51 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --Dvg/WemKV0I3T/Od-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 20 +++- .../expected/role-membership-drop-member.out | 97 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 63 ++++++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) 11.5% src/backend/commands/ 49.2% src/test/isolation/expected/ 38.6% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..ab3cfb0b13f 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role with OID %u does not exist", roleid))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..7daf48395ca --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,97 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans +step s1_begin: BEGIN; +step s1_set_role: SET ROLE regress_role_member_cu; +step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER; +step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member_cu: <... completed> +step s1_reset: RESET ROLE; +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..054326ac535 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,63 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; + CREATE ROLE regress_role_group_cu; + CREATE ROLE regress_role_member_cu; + GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; + DROP ROLE IF EXISTS regress_role_member_cu; + DROP ROLE IF EXISTS regress_role_group_cu; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_set_role { SET ROLE regress_role_member_cu; } +step s1_grant_cu { GRANT regress_role_group_cu TO CURRENT_USER; } +step s1_commit { COMMIT; } +step s1_reset { RESET ROLE; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_drop_member_cu { DROP ROLE regress_role_member_cu; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member and concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER and concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member and concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit + +# GRANT role TO CURRENT_USER and concurrent DROP of the current user +permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans -- 2.34.1 --fotX2lJRknAHpfH+-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 13 +++++- .../expected/role-membership-drop-member.out | 36 ++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 43 +++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) 13.8% src/backend/commands/ 42.9% src/test/isolation/expected/ 42.2% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..05ec753f4f0 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + } result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..e6cae59d2c9 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,36 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..e0140826e52 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,43 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members +# entries. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit + +# ALTER ROLE ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --cOHldBwZEf1OqVbN-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 20 +++- .../expected/role-membership-drop-member.out | 97 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 63 ++++++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) 11.5% src/backend/commands/ 49.2% src/test/isolation/expected/ 38.6% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..ab3cfb0b13f 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role with OID %u does not exist", roleid))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..7daf48395ca --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,97 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans +step s1_begin: BEGIN; +step s1_set_role: SET ROLE regress_role_member_cu; +step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER; +step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member_cu: <... completed> +step s1_reset: RESET ROLE; +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..054326ac535 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,63 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; + CREATE ROLE regress_role_group_cu; + CREATE ROLE regress_role_member_cu; + GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; + DROP ROLE IF EXISTS regress_role_member_cu; + DROP ROLE IF EXISTS regress_role_group_cu; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_set_role { SET ROLE regress_role_member_cu; } +step s1_grant_cu { GRANT regress_role_group_cu TO CURRENT_USER; } +step s1_commit { COMMIT; } +step s1_reset { RESET ROLE; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_drop_member_cu { DROP ROLE regress_role_member_cu; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member and concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER and concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member and concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit + +# GRANT role TO CURRENT_USER and concurrent DROP of the current user +permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans -- 2.34.1 --fotX2lJRknAHpfH+-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 20 +++- .../expected/role-membership-drop-member.out | 97 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 63 ++++++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) 11.5% src/backend/commands/ 49.2% src/test/isolation/expected/ 38.6% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..ab3cfb0b13f 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role with OID %u does not exist", roleid))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..7daf48395ca --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,97 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans +step s1_begin: BEGIN; +step s1_set_role: SET ROLE regress_role_member_cu; +step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER; +step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member_cu: <... completed> +step s1_reset: RESET ROLE; +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..054326ac535 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,63 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; + CREATE ROLE regress_role_group_cu; + CREATE ROLE regress_role_member_cu; + GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; + DROP ROLE IF EXISTS regress_role_member_cu; + DROP ROLE IF EXISTS regress_role_group_cu; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_set_role { SET ROLE regress_role_member_cu; } +step s1_grant_cu { GRANT regress_role_group_cu TO CURRENT_USER; } +step s1_commit { COMMIT; } +step s1_reset { RESET ROLE; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_drop_member_cu { DROP ROLE regress_role_member_cu; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member and concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER and concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member and concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit + +# GRANT role TO CURRENT_USER and concurrent DROP of the current user +permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans -- 2.34.1 --fotX2lJRknAHpfH+-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 21 +++++- .../expected/role-membership-drop-member.out | 75 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 51 +++++++++++++ 4 files changed, 147 insertions(+), 1 deletion(-) 14.9% src/backend/commands/ 48.1% src/test/isolation/expected/ 36.1% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..375fb527af2 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", + get_rolespec_name(rolespec)))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..5b267ea32c2 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,75 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..989fe996249 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,51 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --Dvg/WemKV0I3T/Od-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 13 +++++- .../expected/role-membership-drop-member.out | 36 ++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 43 +++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) 13.8% src/backend/commands/ 42.9% src/test/isolation/expected/ 42.2% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..05ec753f4f0 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + } result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..e6cae59d2c9 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,36 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..e0140826e52 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,43 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members +# entries. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit + +# ALTER ROLE ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --cOHldBwZEf1OqVbN-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 20 +++- .../expected/role-membership-drop-member.out | 97 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 63 ++++++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) 11.5% src/backend/commands/ 49.2% src/test/isolation/expected/ 38.6% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..ab3cfb0b13f 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role with OID %u does not exist", roleid))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..7daf48395ca --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,97 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans +step s1_begin: BEGIN; +step s1_set_role: SET ROLE regress_role_member_cu; +step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER; +step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member_cu: <... completed> +step s1_reset: RESET ROLE; +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..054326ac535 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,63 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; + CREATE ROLE regress_role_group_cu; + CREATE ROLE regress_role_member_cu; + GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; + DROP ROLE IF EXISTS regress_role_member_cu; + DROP ROLE IF EXISTS regress_role_group_cu; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_set_role { SET ROLE regress_role_member_cu; } +step s1_grant_cu { GRANT regress_role_group_cu TO CURRENT_USER; } +step s1_commit { COMMIT; } +step s1_reset { RESET ROLE; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_drop_member_cu { DROP ROLE regress_role_member_cu; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member and concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER and concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member and concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit + +# GRANT role TO CURRENT_USER and concurrent DROP of the current user +permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans -- 2.34.1 --fotX2lJRknAHpfH+-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 20 +++- .../expected/role-membership-drop-member.out | 97 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 63 ++++++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) 11.5% src/backend/commands/ 49.2% src/test/isolation/expected/ 38.6% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..ab3cfb0b13f 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role with OID %u does not exist", roleid))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..7daf48395ca --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,97 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans +step s1_begin: BEGIN; +step s1_set_role: SET ROLE regress_role_member_cu; +step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER; +step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member_cu: <... completed> +step s1_reset: RESET ROLE; +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..054326ac535 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,63 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; + CREATE ROLE regress_role_group_cu; + CREATE ROLE regress_role_member_cu; + GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; + DROP ROLE IF EXISTS regress_role_member_cu; + DROP ROLE IF EXISTS regress_role_group_cu; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_set_role { SET ROLE regress_role_member_cu; } +step s1_grant_cu { GRANT regress_role_group_cu TO CURRENT_USER; } +step s1_commit { COMMIT; } +step s1_reset { RESET ROLE; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_drop_member_cu { DROP ROLE regress_role_member_cu; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member and concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER and concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member and concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit + +# GRANT role TO CURRENT_USER and concurrent DROP of the current user +permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans -- 2.34.1 --fotX2lJRknAHpfH+-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 20 +++- .../expected/role-membership-drop-member.out | 97 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 63 ++++++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) 11.5% src/backend/commands/ 49.2% src/test/isolation/expected/ 38.6% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..ab3cfb0b13f 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role with OID %u does not exist", roleid))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..7daf48395ca --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,97 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans +step s1_begin: BEGIN; +step s1_set_role: SET ROLE regress_role_member_cu; +step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER; +step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member_cu: <... completed> +step s1_reset: RESET ROLE; +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..054326ac535 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,63 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; + CREATE ROLE regress_role_group_cu; + CREATE ROLE regress_role_member_cu; + GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; + DROP ROLE IF EXISTS regress_role_member_cu; + DROP ROLE IF EXISTS regress_role_group_cu; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_set_role { SET ROLE regress_role_member_cu; } +step s1_grant_cu { GRANT regress_role_group_cu TO CURRENT_USER; } +step s1_commit { COMMIT; } +step s1_reset { RESET ROLE; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_drop_member_cu { DROP ROLE regress_role_member_cu; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member and concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER and concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member and concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit + +# GRANT role TO CURRENT_USER and concurrent DROP of the current user +permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans -- 2.34.1 --fotX2lJRknAHpfH+-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 21 +++++- .../expected/role-membership-drop-member.out | 75 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 51 +++++++++++++ 4 files changed, 147 insertions(+), 1 deletion(-) 14.9% src/backend/commands/ 48.1% src/test/isolation/expected/ 36.1% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..375fb527af2 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", + get_rolespec_name(rolespec)))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..5b267ea32c2 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,75 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..989fe996249 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,51 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --Dvg/WemKV0I3T/Od-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 13 +++++- .../expected/role-membership-drop-member.out | 36 ++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 43 +++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) 13.8% src/backend/commands/ 42.9% src/test/isolation/expected/ 42.2% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..05ec753f4f0 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + } result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..e6cae59d2c9 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,36 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..e0140826e52 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,43 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members +# entries. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit + +# ALTER ROLE ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --cOHldBwZEf1OqVbN-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 21 +++++- .../expected/role-membership-drop-member.out | 75 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 51 +++++++++++++ 4 files changed, 147 insertions(+), 1 deletion(-) 14.9% src/backend/commands/ 48.1% src/test/isolation/expected/ 36.1% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..375fb527af2 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", + get_rolespec_name(rolespec)))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..5b267ea32c2 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,75 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..989fe996249 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,51 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --Dvg/WemKV0I3T/Od-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 13 +++++- .../expected/role-membership-drop-member.out | 36 ++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 43 +++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) 13.8% src/backend/commands/ 42.9% src/test/isolation/expected/ 42.2% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..05ec753f4f0 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + } result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..e6cae59d2c9 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,36 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..e0140826e52 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,43 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members +# entries. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit + +# ALTER ROLE ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --cOHldBwZEf1OqVbN-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 13 +++++- .../expected/role-membership-drop-member.out | 36 ++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 43 +++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) 13.8% src/backend/commands/ 42.9% src/test/isolation/expected/ 42.2% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..05ec753f4f0 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + } result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..e6cae59d2c9 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,36 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..e0140826e52 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,43 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members +# entries. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit + +# ALTER ROLE ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --cOHldBwZEf1OqVbN-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 20 +++- .../expected/role-membership-drop-member.out | 97 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 63 ++++++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) 11.5% src/backend/commands/ 49.2% src/test/isolation/expected/ 38.6% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..ab3cfb0b13f 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role with OID %u does not exist", roleid))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..7daf48395ca --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,97 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans +step s1_begin: BEGIN; +step s1_set_role: SET ROLE regress_role_member_cu; +step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER; +step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member_cu: <... completed> +step s1_reset: RESET ROLE; +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..054326ac535 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,63 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; + CREATE ROLE regress_role_group_cu; + CREATE ROLE regress_role_member_cu; + GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; + DROP ROLE IF EXISTS regress_role_member_cu; + DROP ROLE IF EXISTS regress_role_group_cu; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_set_role { SET ROLE regress_role_member_cu; } +step s1_grant_cu { GRANT regress_role_group_cu TO CURRENT_USER; } +step s1_commit { COMMIT; } +step s1_reset { RESET ROLE; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_drop_member_cu { DROP ROLE regress_role_member_cu; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member and concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER and concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member and concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit + +# GRANT role TO CURRENT_USER and concurrent DROP of the current user +permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans -- 2.34.1 --fotX2lJRknAHpfH+-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 20 +++- .../expected/role-membership-drop-member.out | 97 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 63 ++++++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) 11.5% src/backend/commands/ 49.2% src/test/isolation/expected/ 38.6% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..ab3cfb0b13f 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role with OID %u does not exist", roleid))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..7daf48395ca --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,97 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans +step s1_begin: BEGIN; +step s1_set_role: SET ROLE regress_role_member_cu; +step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER; +step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member_cu: <... completed> +step s1_reset: RESET ROLE; +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..054326ac535 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,63 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; + CREATE ROLE regress_role_group_cu; + CREATE ROLE regress_role_member_cu; + GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; + DROP ROLE IF EXISTS regress_role_member_cu; + DROP ROLE IF EXISTS regress_role_group_cu; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_set_role { SET ROLE regress_role_member_cu; } +step s1_grant_cu { GRANT regress_role_group_cu TO CURRENT_USER; } +step s1_commit { COMMIT; } +step s1_reset { RESET ROLE; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_drop_member_cu { DROP ROLE regress_role_member_cu; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member and concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER and concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member and concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit + +# GRANT role TO CURRENT_USER and concurrent DROP of the current user +permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans -- 2.34.1 --fotX2lJRknAHpfH+-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 20 +++- .../expected/role-membership-drop-member.out | 97 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 63 ++++++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) 11.5% src/backend/commands/ 49.2% src/test/isolation/expected/ 38.6% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..ab3cfb0b13f 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role with OID %u does not exist", roleid))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..7daf48395ca --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,97 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans +step s1_begin: BEGIN; +step s1_set_role: SET ROLE regress_role_member_cu; +step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER; +step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member_cu: <... completed> +step s1_reset: RESET ROLE; +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..054326ac535 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,63 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; + CREATE ROLE regress_role_group_cu; + CREATE ROLE regress_role_member_cu; + GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; + DROP ROLE IF EXISTS regress_role_member_cu; + DROP ROLE IF EXISTS regress_role_group_cu; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_set_role { SET ROLE regress_role_member_cu; } +step s1_grant_cu { GRANT regress_role_group_cu TO CURRENT_USER; } +step s1_commit { COMMIT; } +step s1_reset { RESET ROLE; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_drop_member_cu { DROP ROLE regress_role_member_cu; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member and concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER and concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member and concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit + +# GRANT role TO CURRENT_USER and concurrent DROP of the current user +permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans -- 2.34.1 --fotX2lJRknAHpfH+-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 13 +++++- .../expected/role-membership-drop-member.out | 36 ++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 43 +++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) 13.8% src/backend/commands/ 42.9% src/test/isolation/expected/ 42.2% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..05ec753f4f0 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + } result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..e6cae59d2c9 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,36 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..e0140826e52 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,43 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members +# entries. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit + +# ALTER ROLE ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --cOHldBwZEf1OqVbN-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 13 +++++- .../expected/role-membership-drop-member.out | 36 ++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 43 +++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) 13.8% src/backend/commands/ 42.9% src/test/isolation/expected/ 42.2% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..05ec753f4f0 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + } result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..e6cae59d2c9 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,36 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..e0140826e52 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,43 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members +# entries. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit + +# ALTER ROLE ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --cOHldBwZEf1OqVbN-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 20 +++- .../expected/role-membership-drop-member.out | 97 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 63 ++++++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) 11.5% src/backend/commands/ 49.2% src/test/isolation/expected/ 38.6% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..ab3cfb0b13f 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role with OID %u does not exist", roleid))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..7daf48395ca --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,97 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans +step s1_begin: BEGIN; +step s1_set_role: SET ROLE regress_role_member_cu; +step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER; +step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member_cu: <... completed> +step s1_reset: RESET ROLE; +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..054326ac535 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,63 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; + CREATE ROLE regress_role_group_cu; + CREATE ROLE regress_role_member_cu; + GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; + DROP ROLE IF EXISTS regress_role_member_cu; + DROP ROLE IF EXISTS regress_role_group_cu; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_set_role { SET ROLE regress_role_member_cu; } +step s1_grant_cu { GRANT regress_role_group_cu TO CURRENT_USER; } +step s1_commit { COMMIT; } +step s1_reset { RESET ROLE; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_drop_member_cu { DROP ROLE regress_role_member_cu; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member and concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER and concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member and concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit + +# GRANT role TO CURRENT_USER and concurrent DROP of the current user +permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans -- 2.34.1 --fotX2lJRknAHpfH+-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 21 +++++- .../expected/role-membership-drop-member.out | 75 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 51 +++++++++++++ 4 files changed, 147 insertions(+), 1 deletion(-) 14.9% src/backend/commands/ 48.1% src/test/isolation/expected/ 36.1% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..375fb527af2 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", + get_rolespec_name(rolespec)))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..5b267ea32c2 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,75 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..989fe996249 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,51 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --Dvg/WemKV0I3T/Od-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 20 +++- .../expected/role-membership-drop-member.out | 97 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 63 ++++++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) 11.5% src/backend/commands/ 49.2% src/test/isolation/expected/ 38.6% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..ab3cfb0b13f 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role with OID %u does not exist", roleid))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..7daf48395ca --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,97 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans +step s1_begin: BEGIN; +step s1_set_role: SET ROLE regress_role_member_cu; +step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER; +step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member_cu: <... completed> +step s1_reset: RESET ROLE; +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..054326ac535 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,63 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; + CREATE ROLE regress_role_group_cu; + CREATE ROLE regress_role_member_cu; + GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; + DROP ROLE IF EXISTS regress_role_member_cu; + DROP ROLE IF EXISTS regress_role_group_cu; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_set_role { SET ROLE regress_role_member_cu; } +step s1_grant_cu { GRANT regress_role_group_cu TO CURRENT_USER; } +step s1_commit { COMMIT; } +step s1_reset { RESET ROLE; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_drop_member_cu { DROP ROLE regress_role_member_cu; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member and concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER and concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member and concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit + +# GRANT role TO CURRENT_USER and concurrent DROP of the current user +permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans -- 2.34.1 --fotX2lJRknAHpfH+-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 20 +++- .../expected/role-membership-drop-member.out | 97 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 63 ++++++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) 11.5% src/backend/commands/ 49.2% src/test/isolation/expected/ 38.6% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..ab3cfb0b13f 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role with OID %u does not exist", roleid))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..7daf48395ca --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,97 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans +step s1_begin: BEGIN; +step s1_set_role: SET ROLE regress_role_member_cu; +step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER; +step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member_cu: <... completed> +step s1_reset: RESET ROLE; +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..054326ac535 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,63 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; + CREATE ROLE regress_role_group_cu; + CREATE ROLE regress_role_member_cu; + GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; + DROP ROLE IF EXISTS regress_role_member_cu; + DROP ROLE IF EXISTS regress_role_group_cu; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_set_role { SET ROLE regress_role_member_cu; } +step s1_grant_cu { GRANT regress_role_group_cu TO CURRENT_USER; } +step s1_commit { COMMIT; } +step s1_reset { RESET ROLE; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_drop_member_cu { DROP ROLE regress_role_member_cu; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member and concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER and concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member and concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit + +# GRANT role TO CURRENT_USER and concurrent DROP of the current user +permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans -- 2.34.1 --fotX2lJRknAHpfH+-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 20 +++- .../expected/role-membership-drop-member.out | 97 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 63 ++++++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) 11.5% src/backend/commands/ 49.2% src/test/isolation/expected/ 38.6% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..ab3cfb0b13f 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role with OID %u does not exist", roleid))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..7daf48395ca --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,97 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans +step s1_begin: BEGIN; +step s1_set_role: SET ROLE regress_role_member_cu; +step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER; +step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member_cu: <... completed> +step s1_reset: RESET ROLE; +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..054326ac535 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,63 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; + CREATE ROLE regress_role_group_cu; + CREATE ROLE regress_role_member_cu; + GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; + DROP ROLE IF EXISTS regress_role_member_cu; + DROP ROLE IF EXISTS regress_role_group_cu; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_set_role { SET ROLE regress_role_member_cu; } +step s1_grant_cu { GRANT regress_role_group_cu TO CURRENT_USER; } +step s1_commit { COMMIT; } +step s1_reset { RESET ROLE; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_drop_member_cu { DROP ROLE regress_role_member_cu; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member and concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER and concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member and concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit + +# GRANT role TO CURRENT_USER and concurrent DROP of the current user +permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans -- 2.34.1 --fotX2lJRknAHpfH+-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 21 +++++- .../expected/role-membership-drop-member.out | 75 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 51 +++++++++++++ 4 files changed, 147 insertions(+), 1 deletion(-) 14.9% src/backend/commands/ 48.1% src/test/isolation/expected/ 36.1% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..375fb527af2 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", + get_rolespec_name(rolespec)))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..5b267ea32c2 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,75 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..989fe996249 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,51 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --Dvg/WemKV0I3T/Od-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 13 +++++- .../expected/role-membership-drop-member.out | 36 ++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 43 +++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) 13.8% src/backend/commands/ 42.9% src/test/isolation/expected/ 42.2% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..05ec753f4f0 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + } result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..e6cae59d2c9 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,36 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..e0140826e52 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,43 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members +# entries. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit + +# ALTER ROLE ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --cOHldBwZEf1OqVbN-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 21 +++++- .../expected/role-membership-drop-member.out | 75 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 51 +++++++++++++ 4 files changed, 147 insertions(+), 1 deletion(-) 14.9% src/backend/commands/ 48.1% src/test/isolation/expected/ 36.1% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..375fb527af2 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", + get_rolespec_name(rolespec)))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..5b267ea32c2 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,75 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..989fe996249 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,51 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --Dvg/WemKV0I3T/Od-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 21 +++++- .../expected/role-membership-drop-member.out | 75 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 51 +++++++++++++ 4 files changed, 147 insertions(+), 1 deletion(-) 14.9% src/backend/commands/ 48.1% src/test/isolation/expected/ 36.1% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..375fb527af2 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", + get_rolespec_name(rolespec)))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..5b267ea32c2 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,75 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..989fe996249 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,51 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --Dvg/WemKV0I3T/Od-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 20 +++- .../expected/role-membership-drop-member.out | 97 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 63 ++++++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) 11.5% src/backend/commands/ 49.2% src/test/isolation/expected/ 38.6% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..ab3cfb0b13f 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role with OID %u does not exist", roleid))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..7daf48395ca --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,97 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans +step s1_begin: BEGIN; +step s1_set_role: SET ROLE regress_role_member_cu; +step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER; +step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member_cu: <... completed> +step s1_reset: RESET ROLE; +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..054326ac535 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,63 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; + CREATE ROLE regress_role_group_cu; + CREATE ROLE regress_role_member_cu; + GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; + DROP ROLE IF EXISTS regress_role_member_cu; + DROP ROLE IF EXISTS regress_role_group_cu; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_set_role { SET ROLE regress_role_member_cu; } +step s1_grant_cu { GRANT regress_role_group_cu TO CURRENT_USER; } +step s1_commit { COMMIT; } +step s1_reset { RESET ROLE; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_drop_member_cu { DROP ROLE regress_role_member_cu; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member and concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER and concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member and concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit + +# GRANT role TO CURRENT_USER and concurrent DROP of the current user +permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans -- 2.34.1 --fotX2lJRknAHpfH+-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 20 +++- .../expected/role-membership-drop-member.out | 97 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 63 ++++++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) 11.5% src/backend/commands/ 49.2% src/test/isolation/expected/ 38.6% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..ab3cfb0b13f 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role with OID %u does not exist", roleid))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..7daf48395ca --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,97 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans +step s1_begin: BEGIN; +step s1_set_role: SET ROLE regress_role_member_cu; +step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER; +step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member_cu: <... completed> +step s1_reset: RESET ROLE; +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..054326ac535 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,63 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; + CREATE ROLE regress_role_group_cu; + CREATE ROLE regress_role_member_cu; + GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; + DROP ROLE IF EXISTS regress_role_member_cu; + DROP ROLE IF EXISTS regress_role_group_cu; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_set_role { SET ROLE regress_role_member_cu; } +step s1_grant_cu { GRANT regress_role_group_cu TO CURRENT_USER; } +step s1_commit { COMMIT; } +step s1_reset { RESET ROLE; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_drop_member_cu { DROP ROLE regress_role_member_cu; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member and concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER and concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member and concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit + +# GRANT role TO CURRENT_USER and concurrent DROP of the current user +permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans -- 2.34.1 --fotX2lJRknAHpfH+-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 20 +++- .../expected/role-membership-drop-member.out | 97 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 63 ++++++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) 11.5% src/backend/commands/ 49.2% src/test/isolation/expected/ 38.6% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..ab3cfb0b13f 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role with OID %u does not exist", roleid))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..7daf48395ca --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,97 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans +step s1_begin: BEGIN; +step s1_set_role: SET ROLE regress_role_member_cu; +step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER; +step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member_cu: <... completed> +step s1_reset: RESET ROLE; +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..054326ac535 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,63 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; + CREATE ROLE regress_role_group_cu; + CREATE ROLE regress_role_member_cu; + GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; + DROP ROLE IF EXISTS regress_role_member_cu; + DROP ROLE IF EXISTS regress_role_group_cu; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_set_role { SET ROLE regress_role_member_cu; } +step s1_grant_cu { GRANT regress_role_group_cu TO CURRENT_USER; } +step s1_commit { COMMIT; } +step s1_reset { RESET ROLE; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_drop_member_cu { DROP ROLE regress_role_member_cu; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member and concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER and concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member and concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit + +# GRANT role TO CURRENT_USER and concurrent DROP of the current user +permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans -- 2.34.1 --fotX2lJRknAHpfH+-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 21 +++++- .../expected/role-membership-drop-member.out | 75 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 51 +++++++++++++ 4 files changed, 147 insertions(+), 1 deletion(-) 14.9% src/backend/commands/ 48.1% src/test/isolation/expected/ 36.1% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..375fb527af2 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", + get_rolespec_name(rolespec)))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..5b267ea32c2 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,75 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..989fe996249 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,51 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --Dvg/WemKV0I3T/Od-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 21 +++++- .../expected/role-membership-drop-member.out | 75 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 51 +++++++++++++ 4 files changed, 147 insertions(+), 1 deletion(-) 14.9% src/backend/commands/ 48.1% src/test/isolation/expected/ 36.1% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..375fb527af2 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", + get_rolespec_name(rolespec)))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..5b267ea32c2 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,75 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..989fe996249 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,51 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --Dvg/WemKV0I3T/Od-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 13 +++++- .../expected/role-membership-drop-member.out | 36 ++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 43 +++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) 13.8% src/backend/commands/ 42.9% src/test/isolation/expected/ 42.2% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..05ec753f4f0 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + } result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..e6cae59d2c9 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,36 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..e0140826e52 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,43 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members +# entries. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit + +# ALTER ROLE ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --cOHldBwZEf1OqVbN-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 21 +++++- .../expected/role-membership-drop-member.out | 75 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 51 +++++++++++++ 4 files changed, 147 insertions(+), 1 deletion(-) 14.9% src/backend/commands/ 48.1% src/test/isolation/expected/ 36.1% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..375fb527af2 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", + get_rolespec_name(rolespec)))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..5b267ea32c2 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,75 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..989fe996249 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,51 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --Dvg/WemKV0I3T/Od-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 21 +++++- .../expected/role-membership-drop-member.out | 75 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 51 +++++++++++++ 4 files changed, 147 insertions(+), 1 deletion(-) 14.9% src/backend/commands/ 48.1% src/test/isolation/expected/ 36.1% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..375fb527af2 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", + get_rolespec_name(rolespec)))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..5b267ea32c2 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,75 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..989fe996249 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,51 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --Dvg/WemKV0I3T/Od-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 20 +++- .../expected/role-membership-drop-member.out | 97 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 63 ++++++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) 11.5% src/backend/commands/ 49.2% src/test/isolation/expected/ 38.6% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..ab3cfb0b13f 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role with OID %u does not exist", roleid))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..7daf48395ca --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,97 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans +step s1_begin: BEGIN; +step s1_set_role: SET ROLE regress_role_member_cu; +step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER; +step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member_cu: <... completed> +step s1_reset: RESET ROLE; +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..054326ac535 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,63 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; + CREATE ROLE regress_role_group_cu; + CREATE ROLE regress_role_member_cu; + GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; + DROP ROLE IF EXISTS regress_role_member_cu; + DROP ROLE IF EXISTS regress_role_group_cu; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_set_role { SET ROLE regress_role_member_cu; } +step s1_grant_cu { GRANT regress_role_group_cu TO CURRENT_USER; } +step s1_commit { COMMIT; } +step s1_reset { RESET ROLE; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_drop_member_cu { DROP ROLE regress_role_member_cu; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member and concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER and concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member and concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit + +# GRANT role TO CURRENT_USER and concurrent DROP of the current user +permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans -- 2.34.1 --fotX2lJRknAHpfH+-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 21 +++++- .../expected/role-membership-drop-member.out | 75 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 51 +++++++++++++ 4 files changed, 147 insertions(+), 1 deletion(-) 14.9% src/backend/commands/ 48.1% src/test/isolation/expected/ 36.1% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..375fb527af2 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", + get_rolespec_name(rolespec)))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..5b267ea32c2 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,75 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..989fe996249 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,51 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --Dvg/WemKV0I3T/Od-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 13 +++++- .../expected/role-membership-drop-member.out | 36 ++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 43 +++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) 13.8% src/backend/commands/ 42.9% src/test/isolation/expected/ 42.2% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..05ec753f4f0 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + } result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..e6cae59d2c9 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,36 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..e0140826e52 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,43 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members +# entries. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit + +# ALTER ROLE ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --cOHldBwZEf1OqVbN-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 20 +++- .../expected/role-membership-drop-member.out | 97 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 63 ++++++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) 11.5% src/backend/commands/ 49.2% src/test/isolation/expected/ 38.6% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..ab3cfb0b13f 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role with OID %u does not exist", roleid))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..7daf48395ca --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,97 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans +step s1_begin: BEGIN; +step s1_set_role: SET ROLE regress_role_member_cu; +step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER; +step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member_cu: <... completed> +step s1_reset: RESET ROLE; +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..054326ac535 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,63 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; + CREATE ROLE regress_role_group_cu; + CREATE ROLE regress_role_member_cu; + GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; + DROP ROLE IF EXISTS regress_role_member_cu; + DROP ROLE IF EXISTS regress_role_group_cu; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_set_role { SET ROLE regress_role_member_cu; } +step s1_grant_cu { GRANT regress_role_group_cu TO CURRENT_USER; } +step s1_commit { COMMIT; } +step s1_reset { RESET ROLE; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_drop_member_cu { DROP ROLE regress_role_member_cu; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member and concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER and concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member and concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit + +# GRANT role TO CURRENT_USER and concurrent DROP of the current user +permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans -- 2.34.1 --fotX2lJRknAHpfH+-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 21 +++++- .../expected/role-membership-drop-member.out | 75 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 51 +++++++++++++ 4 files changed, 147 insertions(+), 1 deletion(-) 14.9% src/backend/commands/ 48.1% src/test/isolation/expected/ 36.1% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..375fb527af2 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", + get_rolespec_name(rolespec)))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..5b267ea32c2 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,75 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..989fe996249 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,51 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --Dvg/WemKV0I3T/Od-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 13 +++++- .../expected/role-membership-drop-member.out | 36 ++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 43 +++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) 13.8% src/backend/commands/ 42.9% src/test/isolation/expected/ 42.2% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..05ec753f4f0 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + } result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..e6cae59d2c9 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,36 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..e0140826e52 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,43 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members +# entries. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit + +# ALTER ROLE ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --cOHldBwZEf1OqVbN-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 21 +++++- .../expected/role-membership-drop-member.out | 75 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 51 +++++++++++++ 4 files changed, 147 insertions(+), 1 deletion(-) 14.9% src/backend/commands/ 48.1% src/test/isolation/expected/ 36.1% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..375fb527af2 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", + get_rolespec_name(rolespec)))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..5b267ea32c2 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,75 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..989fe996249 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,51 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --Dvg/WemKV0I3T/Od-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 21 +++++- .../expected/role-membership-drop-member.out | 75 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 51 +++++++++++++ 4 files changed, 147 insertions(+), 1 deletion(-) 14.9% src/backend/commands/ 48.1% src/test/isolation/expected/ 36.1% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..375fb527af2 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", + get_rolespec_name(rolespec)))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..5b267ea32c2 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,75 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..989fe996249 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,51 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --Dvg/WemKV0I3T/Od-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 13 +++++- .../expected/role-membership-drop-member.out | 36 ++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 43 +++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) 13.8% src/backend/commands/ 42.9% src/test/isolation/expected/ 42.2% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..05ec753f4f0 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + } result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..e6cae59d2c9 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,36 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..e0140826e52 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,43 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members +# entries. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit + +# ALTER ROLE ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --cOHldBwZEf1OqVbN-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 20 +++- .../expected/role-membership-drop-member.out | 97 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 63 ++++++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) 11.5% src/backend/commands/ 49.2% src/test/isolation/expected/ 38.6% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..ab3cfb0b13f 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role with OID %u does not exist", roleid))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..7daf48395ca --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,97 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans +step s1_begin: BEGIN; +step s1_set_role: SET ROLE regress_role_member_cu; +step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER; +step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member_cu: <... completed> +step s1_reset: RESET ROLE; +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..054326ac535 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,63 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; + CREATE ROLE regress_role_group_cu; + CREATE ROLE regress_role_member_cu; + GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; + DROP ROLE IF EXISTS regress_role_member_cu; + DROP ROLE IF EXISTS regress_role_group_cu; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_set_role { SET ROLE regress_role_member_cu; } +step s1_grant_cu { GRANT regress_role_group_cu TO CURRENT_USER; } +step s1_commit { COMMIT; } +step s1_reset { RESET ROLE; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_drop_member_cu { DROP ROLE regress_role_member_cu; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member and concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER and concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member and concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit + +# GRANT role TO CURRENT_USER and concurrent DROP of the current user +permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans -- 2.34.1 --fotX2lJRknAHpfH+-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 20 +++- .../expected/role-membership-drop-member.out | 97 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 63 ++++++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) 11.5% src/backend/commands/ 49.2% src/test/isolation/expected/ 38.6% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..ab3cfb0b13f 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role with OID %u does not exist", roleid))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..7daf48395ca --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,97 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans +step s1_begin: BEGIN; +step s1_set_role: SET ROLE regress_role_member_cu; +step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER; +step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member_cu: <... completed> +step s1_reset: RESET ROLE; +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..054326ac535 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,63 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; + CREATE ROLE regress_role_group_cu; + CREATE ROLE regress_role_member_cu; + GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; + DROP ROLE IF EXISTS regress_role_member_cu; + DROP ROLE IF EXISTS regress_role_group_cu; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_set_role { SET ROLE regress_role_member_cu; } +step s1_grant_cu { GRANT regress_role_group_cu TO CURRENT_USER; } +step s1_commit { COMMIT; } +step s1_reset { RESET ROLE; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_drop_member_cu { DROP ROLE regress_role_member_cu; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member and concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER and concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member and concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit + +# GRANT role TO CURRENT_USER and concurrent DROP of the current user +permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans -- 2.34.1 --fotX2lJRknAHpfH+-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 13 +++++- .../expected/role-membership-drop-member.out | 36 ++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 43 +++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) 13.8% src/backend/commands/ 42.9% src/test/isolation/expected/ 42.2% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..05ec753f4f0 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + } result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..e6cae59d2c9 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,36 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..e0140826e52 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,43 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members +# entries. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit + +# ALTER ROLE ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --cOHldBwZEf1OqVbN-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 13 +++++- .../expected/role-membership-drop-member.out | 36 ++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 43 +++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) 13.8% src/backend/commands/ 42.9% src/test/isolation/expected/ 42.2% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..05ec753f4f0 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + } result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..e6cae59d2c9 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,36 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..e0140826e52 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,43 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members +# entries. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit + +# ALTER ROLE ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --cOHldBwZEf1OqVbN-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 21 +++++- .../expected/role-membership-drop-member.out | 75 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 51 +++++++++++++ 4 files changed, 147 insertions(+), 1 deletion(-) 14.9% src/backend/commands/ 48.1% src/test/isolation/expected/ 36.1% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..375fb527af2 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", + get_rolespec_name(rolespec)))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..5b267ea32c2 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,75 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..989fe996249 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,51 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --Dvg/WemKV0I3T/Od-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 13 +++++- .../expected/role-membership-drop-member.out | 36 ++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 43 +++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) 13.8% src/backend/commands/ 42.9% src/test/isolation/expected/ 42.2% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..05ec753f4f0 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + } result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..e6cae59d2c9 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,36 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..e0140826e52 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,43 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members +# entries. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit + +# ALTER ROLE ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --cOHldBwZEf1OqVbN-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 13 +++++- .../expected/role-membership-drop-member.out | 36 ++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 43 +++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) 13.8% src/backend/commands/ 42.9% src/test/isolation/expected/ 42.2% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..05ec753f4f0 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + } result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..e6cae59d2c9 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,36 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..e0140826e52 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,43 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members +# entries. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit + +# ALTER ROLE ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --cOHldBwZEf1OqVbN-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 13 +++++- .../expected/role-membership-drop-member.out | 36 ++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 43 +++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) 13.8% src/backend/commands/ 42.9% src/test/isolation/expected/ 42.2% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..05ec753f4f0 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + } result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..e6cae59d2c9 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,36 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..e0140826e52 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,43 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members +# entries. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit + +# ALTER ROLE ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --cOHldBwZEf1OqVbN-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 20 +++- .../expected/role-membership-drop-member.out | 97 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 63 ++++++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) 11.5% src/backend/commands/ 49.2% src/test/isolation/expected/ 38.6% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..ab3cfb0b13f 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role with OID %u does not exist", roleid))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..7daf48395ca --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,97 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans +step s1_begin: BEGIN; +step s1_set_role: SET ROLE regress_role_member_cu; +step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER; +step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member_cu: <... completed> +step s1_reset: RESET ROLE; +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..054326ac535 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,63 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; + CREATE ROLE regress_role_group_cu; + CREATE ROLE regress_role_member_cu; + GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; + DROP ROLE IF EXISTS regress_role_member_cu; + DROP ROLE IF EXISTS regress_role_group_cu; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_set_role { SET ROLE regress_role_member_cu; } +step s1_grant_cu { GRANT regress_role_group_cu TO CURRENT_USER; } +step s1_commit { COMMIT; } +step s1_reset { RESET ROLE; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_drop_member_cu { DROP ROLE regress_role_member_cu; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member and concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER and concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member and concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit + +# GRANT role TO CURRENT_USER and concurrent DROP of the current user +permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans -- 2.34.1 --fotX2lJRknAHpfH+-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 20 +++- .../expected/role-membership-drop-member.out | 97 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 63 ++++++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) 11.5% src/backend/commands/ 49.2% src/test/isolation/expected/ 38.6% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..ab3cfb0b13f 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role with OID %u does not exist", roleid))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..7daf48395ca --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,97 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans +step s1_begin: BEGIN; +step s1_set_role: SET ROLE regress_role_member_cu; +step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER; +step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member_cu: <... completed> +step s1_reset: RESET ROLE; +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..054326ac535 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,63 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; + CREATE ROLE regress_role_group_cu; + CREATE ROLE regress_role_member_cu; + GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; + DROP ROLE IF EXISTS regress_role_member_cu; + DROP ROLE IF EXISTS regress_role_group_cu; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_set_role { SET ROLE regress_role_member_cu; } +step s1_grant_cu { GRANT regress_role_group_cu TO CURRENT_USER; } +step s1_commit { COMMIT; } +step s1_reset { RESET ROLE; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_drop_member_cu { DROP ROLE regress_role_member_cu; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member and concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER and concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member and concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit + +# GRANT role TO CURRENT_USER and concurrent DROP of the current user +permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans -- 2.34.1 --fotX2lJRknAHpfH+-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 21 +++++- .../expected/role-membership-drop-member.out | 75 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 51 +++++++++++++ 4 files changed, 147 insertions(+), 1 deletion(-) 14.9% src/backend/commands/ 48.1% src/test/isolation/expected/ 36.1% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..375fb527af2 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", + get_rolespec_name(rolespec)))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..5b267ea32c2 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,75 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..989fe996249 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,51 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --Dvg/WemKV0I3T/Od-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 13 +++++- .../expected/role-membership-drop-member.out | 36 ++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 43 +++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) 13.8% src/backend/commands/ 42.9% src/test/isolation/expected/ 42.2% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..05ec753f4f0 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + } result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..e6cae59d2c9 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,36 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..e0140826e52 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,43 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members +# entries. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit + +# ALTER ROLE ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --cOHldBwZEf1OqVbN-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 20 +++- .../expected/role-membership-drop-member.out | 97 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 63 ++++++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) 11.5% src/backend/commands/ 49.2% src/test/isolation/expected/ 38.6% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..ab3cfb0b13f 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role with OID %u does not exist", roleid))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..7daf48395ca --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,97 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans +step s1_begin: BEGIN; +step s1_set_role: SET ROLE regress_role_member_cu; +step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER; +step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member_cu: <... completed> +step s1_reset: RESET ROLE; +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..054326ac535 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,63 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; + CREATE ROLE regress_role_group_cu; + CREATE ROLE regress_role_member_cu; + GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; + DROP ROLE IF EXISTS regress_role_member_cu; + DROP ROLE IF EXISTS regress_role_group_cu; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_set_role { SET ROLE regress_role_member_cu; } +step s1_grant_cu { GRANT regress_role_group_cu TO CURRENT_USER; } +step s1_commit { COMMIT; } +step s1_reset { RESET ROLE; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_drop_member_cu { DROP ROLE regress_role_member_cu; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member and concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER and concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member and concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit + +# GRANT role TO CURRENT_USER and concurrent DROP of the current user +permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans -- 2.34.1 --fotX2lJRknAHpfH+-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 13 +++++- .../expected/role-membership-drop-member.out | 36 ++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 43 +++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) 13.8% src/backend/commands/ 42.9% src/test/isolation/expected/ 42.2% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..05ec753f4f0 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + } result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..e6cae59d2c9 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,36 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..e0140826e52 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,43 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members +# entries. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit + +# ALTER ROLE ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --cOHldBwZEf1OqVbN-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 20 +++- .../expected/role-membership-drop-member.out | 97 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 63 ++++++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) 11.5% src/backend/commands/ 49.2% src/test/isolation/expected/ 38.6% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..ab3cfb0b13f 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role with OID %u does not exist", roleid))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..7daf48395ca --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,97 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans +step s1_begin: BEGIN; +step s1_set_role: SET ROLE regress_role_member_cu; +step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER; +step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member_cu: <... completed> +step s1_reset: RESET ROLE; +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..054326ac535 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,63 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; + CREATE ROLE regress_role_group_cu; + CREATE ROLE regress_role_member_cu; + GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; + DROP ROLE IF EXISTS regress_role_member_cu; + DROP ROLE IF EXISTS regress_role_group_cu; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_set_role { SET ROLE regress_role_member_cu; } +step s1_grant_cu { GRANT regress_role_group_cu TO CURRENT_USER; } +step s1_commit { COMMIT; } +step s1_reset { RESET ROLE; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_drop_member_cu { DROP ROLE regress_role_member_cu; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member and concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER and concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member and concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit + +# GRANT role TO CURRENT_USER and concurrent DROP of the current user +permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans -- 2.34.1 --fotX2lJRknAHpfH+-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 13 +++++- .../expected/role-membership-drop-member.out | 36 ++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 43 +++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) 13.8% src/backend/commands/ 42.9% src/test/isolation/expected/ 42.2% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..05ec753f4f0 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + } result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..e6cae59d2c9 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,36 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..e0140826e52 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,43 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members +# entries. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit + +# ALTER ROLE ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --cOHldBwZEf1OqVbN-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 20 +++- .../expected/role-membership-drop-member.out | 97 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 63 ++++++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) 11.5% src/backend/commands/ 49.2% src/test/isolation/expected/ 38.6% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..ab3cfb0b13f 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role with OID %u does not exist", roleid))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..7daf48395ca --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,97 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans +step s1_begin: BEGIN; +step s1_set_role: SET ROLE regress_role_member_cu; +step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER; +step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member_cu: <... completed> +step s1_reset: RESET ROLE; +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..054326ac535 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,63 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; + CREATE ROLE regress_role_group_cu; + CREATE ROLE regress_role_member_cu; + GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; + DROP ROLE IF EXISTS regress_role_member_cu; + DROP ROLE IF EXISTS regress_role_group_cu; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_set_role { SET ROLE regress_role_member_cu; } +step s1_grant_cu { GRANT regress_role_group_cu TO CURRENT_USER; } +step s1_commit { COMMIT; } +step s1_reset { RESET ROLE; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_drop_member_cu { DROP ROLE regress_role_member_cu; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member and concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER and concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member and concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit + +# GRANT role TO CURRENT_USER and concurrent DROP of the current user +permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans -- 2.34.1 --fotX2lJRknAHpfH+-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 21 +++++- .../expected/role-membership-drop-member.out | 75 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 51 +++++++++++++ 4 files changed, 147 insertions(+), 1 deletion(-) 14.9% src/backend/commands/ 48.1% src/test/isolation/expected/ 36.1% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..375fb527af2 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", + get_rolespec_name(rolespec)))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..5b267ea32c2 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,75 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..989fe996249 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,51 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --Dvg/WemKV0I3T/Od-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 21 +++++- .../expected/role-membership-drop-member.out | 75 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 51 +++++++++++++ 4 files changed, 147 insertions(+), 1 deletion(-) 14.9% src/backend/commands/ 48.1% src/test/isolation/expected/ 36.1% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..375fb527af2 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", + get_rolespec_name(rolespec)))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..5b267ea32c2 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,75 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..989fe996249 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,51 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --Dvg/WemKV0I3T/Od-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 21 +++++- .../expected/role-membership-drop-member.out | 75 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 51 +++++++++++++ 4 files changed, 147 insertions(+), 1 deletion(-) 14.9% src/backend/commands/ 48.1% src/test/isolation/expected/ 36.1% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..375fb527af2 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,24 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role \"%s\" does not exist", + get_rolespec_name(rolespec)))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..5b267ea32c2 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,75 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..989fe996249 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,51 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --Dvg/WemKV0I3T/Od-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 20 +++- .../expected/role-membership-drop-member.out | 97 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 63 ++++++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) 11.5% src/backend/commands/ 49.2% src/test/isolation/expected/ 38.6% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..ab3cfb0b13f 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role with OID %u does not exist", roleid))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..7daf48395ca --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,97 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans +step s1_begin: BEGIN; +step s1_set_role: SET ROLE regress_role_member_cu; +step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER; +step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member_cu: <... completed> +step s1_reset: RESET ROLE; +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..054326ac535 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,63 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; + CREATE ROLE regress_role_group_cu; + CREATE ROLE regress_role_member_cu; + GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; + DROP ROLE IF EXISTS regress_role_member_cu; + DROP ROLE IF EXISTS regress_role_group_cu; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_set_role { SET ROLE regress_role_member_cu; } +step s1_grant_cu { GRANT regress_role_group_cu TO CURRENT_USER; } +step s1_commit { COMMIT; } +step s1_reset { RESET ROLE; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_drop_member_cu { DROP ROLE regress_role_member_cu; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member and concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER and concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member and concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit + +# GRANT role TO CURRENT_USER and concurrent DROP of the current user +permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans -- 2.34.1 --fotX2lJRknAHpfH+-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 20 +++- .../expected/role-membership-drop-member.out | 97 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 63 ++++++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) 11.5% src/backend/commands/ 49.2% src/test/isolation/expected/ 38.6% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..ab3cfb0b13f 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role with OID %u does not exist", roleid))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..7daf48395ca --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,97 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans +step s1_begin: BEGIN; +step s1_set_role: SET ROLE regress_role_member_cu; +step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER; +step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member_cu: <... completed> +step s1_reset: RESET ROLE; +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..054326ac535 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,63 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; + CREATE ROLE regress_role_group_cu; + CREATE ROLE regress_role_member_cu; + GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; + DROP ROLE IF EXISTS regress_role_member_cu; + DROP ROLE IF EXISTS regress_role_group_cu; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_set_role { SET ROLE regress_role_member_cu; } +step s1_grant_cu { GRANT regress_role_group_cu TO CURRENT_USER; } +step s1_commit { COMMIT; } +step s1_reset { RESET ROLE; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_drop_member_cu { DROP ROLE regress_role_member_cu; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member and concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER and concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member and concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit + +# GRANT role TO CURRENT_USER and concurrent DROP of the current user +permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans -- 2.34.1 --fotX2lJRknAHpfH+-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 13 +++++- .../expected/role-membership-drop-member.out | 36 ++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 43 +++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) 13.8% src/backend/commands/ 42.9% src/test/isolation/expected/ 42.2% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..05ec753f4f0 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + } result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..e6cae59d2c9 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,36 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..e0140826e52 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,43 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members +# entries. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit + +# ALTER ROLE ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --cOHldBwZEf1OqVbN-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. The AccessShareLock is held until end of transaction, so an open transaction that performed GRANT, CREATE ROLE ... ROLE, or REASSIGN OWNED BY will block a concurrent DROP ROLE on the same role until it commits. Note that this introduces a potential deadlock between GRANT and DROP ROLE when both target overlapping roles. The deadlock is detected and one session is aborted with an error, which is preferable to the pre-patch behavior of silently creating orphaned catalog entries. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Surya Poondla <[email protected]> Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 20 +++- .../expected/role-membership-drop-member.out | 97 +++++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 63 ++++++++++++ 4 files changed, 180 insertions(+), 1 deletion(-) 11.5% src/backend/commands/ 49.2% src/test/isolation/expected/ 38.6% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..ab3cfb0b13f 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,23 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + + /* Recheck that the role still exists after locking. */ + if (!SearchSysCacheExists1(AUTHOID, ObjectIdGetDatum(roleid))) + ereport(ERROR, + (errcode(ERRCODE_UNDEFINED_OBJECT), + errmsg("role with OID %u does not exist", roleid))); + } + result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..7daf48395ca --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,97 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans +step s1_begin: BEGIN; +step s1_set_role: SET ROLE regress_role_member_cu; +step s1_grant_cu: GRANT regress_role_group_cu TO CURRENT_USER; +step s2_drop_member_cu: DROP ROLE regress_role_member_cu; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member_cu: <... completed> +step s1_reset: RESET ROLE; +step s2_check_orphans: + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; + +count +----- + 0 +(1 row) + diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..054326ac535 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,63 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned pg_auth_members +# entries, or from operating on a stale OID. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; + CREATE ROLE regress_role_group_cu; + CREATE ROLE regress_role_member_cu; + GRANT regress_role_group_cu TO regress_role_member_cu WITH ADMIN OPTION; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; + DROP ROLE IF EXISTS regress_role_member_cu; + DROP ROLE IF EXISTS regress_role_group_cu; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_set_role { SET ROLE regress_role_member_cu; } +step s1_grant_cu { GRANT regress_role_group_cu TO CURRENT_USER; } +step s1_commit { COMMIT; } +step s1_reset { RESET ROLE; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } +step s2_drop_member_cu { DROP ROLE regress_role_member_cu; } +step s2_check_orphans { + SELECT count(*) + FROM pg_auth_members m + LEFT JOIN pg_authid ra ON m.roleid = ra.oid + LEFT JOIN pg_authid me ON m.member = me.oid + LEFT JOIN pg_authid gr ON m.grantor = gr.oid + WHERE ra.oid IS NULL OR me.oid IS NULL OR gr.oid IS NULL; +} + +# GRANT role TO member and concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit s2_check_orphans + +# ALTER GROUP ADD USER and concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit s2_check_orphans + +# CREATE ROLE ... ROLE member and concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit s2_check_orphans + +# DROP OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role and concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit + +# GRANT role TO CURRENT_USER and concurrent DROP of the current user +permutation s1_begin s1_set_role s1_grant_cu s2_drop_member_cu s1_commit s1_reset s2_check_orphans -- 2.34.1 --fotX2lJRknAHpfH+-- ^ permalink raw reply [nested|flat] 221+ messages in thread
* [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP @ 2026-07-06 08:28 Bertrand Drouvot <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Bertrand Drouvot @ 2026-07-06 08:28 UTC (permalink / raw) roleSpecsToIds() resolves role names to OIDs without acquiring any lock. A concurrent DROP ROLE that commits between this resolution and the caller's use of the OID leaves the caller operating on a stale OID, which can create orphaned pg_auth_members entries. Fix this by acquiring AccessShareLock on each resolved role within roleSpecsToIds(), ensuring the role cannot be dropped while any caller is using its OID. Author: Bertrand Drouvot <[email protected]> Reported-by: Virender Singla <[email protected]> Reviewed-by: Discussion: https://postgr.es/m/aki6fMNLUx6%2BBR8K%40bdtpg Discussion: https://postgr.es/m/CAM6Zo8woa62ZFHtMKox6a4jb8qQ%3Dw87R2L0K8347iE-juQL2EA%40mail.gmail.com --- src/backend/commands/user.c | 13 +++++- .../expected/role-membership-drop-member.out | 36 ++++++++++++++++ src/test/isolation/isolation_schedule | 1 + .../specs/role-membership-drop-member.spec | 43 +++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) 13.8% src/backend/commands/ 42.9% src/test/isolation/expected/ 42.2% src/test/isolation/specs/ diff --git a/src/backend/commands/user.c b/src/backend/commands/user.c index 5b869e91c17..05ec753f4f0 100644 --- a/src/backend/commands/user.c +++ b/src/backend/commands/user.c @@ -1778,6 +1778,8 @@ ReassignOwnedObjects(ReassignOwnedStmt *stmt) * roleSpecsToIds * * Given a list of RoleSpecs, generate a list of role OIDs in the same order. + * Each role is locked with AccessShareLock to prevent concurrent DROP ROLE + * from removing it between resolution and the caller's catalog update. * * ROLESPEC_PUBLIC is not allowed. */ @@ -1792,7 +1794,16 @@ roleSpecsToIds(List *memberNames) RoleSpec *rolespec = lfirst_node(RoleSpec, l); Oid roleid; - roleid = get_rolespec_oid(rolespec, false); + if (rolespec->roletype == ROLESPEC_CSTRING) + roleid = RoleNameGetOid(rolespec->rolename, + AccessShareLock, false, + NULL, NULL); + else + { + roleid = get_rolespec_oid(rolespec, false); + LockSharedObject(AuthIdRelationId, roleid, 0, + AccessShareLock); + } result = lappend_oid(result, roleid); } return result; diff --git a/src/test/isolation/expected/role-membership-drop-member.out b/src/test/isolation/expected/role-membership-drop-member.out new file mode 100644 index 00000000000..e6cae59d2c9 --- /dev/null +++ b/src/test/isolation/expected/role-membership-drop-member.out @@ -0,0 +1,36 @@ +Parsed test spec with 2 sessions + +starting permutation: s1_begin s1_grant s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_grant: GRANT regress_role_group TO regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_alter_add s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_alter_add: ALTER GROUP regress_role_group ADD USER regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_create_role s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_create_role: CREATE ROLE regress_role_new ROLE regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_drop_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_drop_owned: DROP OWNED BY regress_role_member; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> + +starting permutation: s1_begin s1_reassign_owned s2_drop_member s1_commit +step s1_begin: BEGIN; +step s1_reassign_owned: REASSIGN OWNED BY regress_role_member TO regress_role_group; +step s2_drop_member: DROP ROLE regress_role_member; <waiting ...> +step s1_commit: COMMIT; +step s2_drop_member: <... completed> diff --git a/src/test/isolation/isolation_schedule b/src/test/isolation/isolation_schedule index b8ebe92553c..8fb8b52b77f 100644 --- a/src/test/isolation/isolation_schedule +++ b/src/test/isolation/isolation_schedule @@ -128,3 +128,4 @@ test: matview-write-skew test: lock-nowait test: for-portion-of test: ddl-dependency-locking +test: role-membership-drop-member diff --git a/src/test/isolation/specs/role-membership-drop-member.spec b/src/test/isolation/specs/role-membership-drop-member.spec new file mode 100644 index 00000000000..e0140826e52 --- /dev/null +++ b/src/test/isolation/specs/role-membership-drop-member.spec @@ -0,0 +1,43 @@ +# Test that role membership commands properly lock the grantee/member +# role to prevent concurrent DROP ROLE from creating orphaned # pg_auth_members +# entries. + +setup +{ + CREATE ROLE regress_role_group; + CREATE ROLE regress_role_member; +} + +teardown +{ + DROP ROLE IF EXISTS regress_role_group; + DROP ROLE IF EXISTS regress_role_member; + DROP ROLE IF EXISTS regress_role_new; +} + +session s1 +step s1_begin { BEGIN; } +step s1_grant { GRANT regress_role_group TO regress_role_member; } +step s1_alter_add { ALTER GROUP regress_role_group ADD USER regress_role_member; } +step s1_create_role { CREATE ROLE regress_role_new ROLE regress_role_member; } +step s1_drop_owned { DROP OWNED BY regress_role_member; } +step s1_reassign_owned { REASSIGN OWNED BY regress_role_member TO regress_role_group; } +step s1_commit { COMMIT; } + +session s2 +step s2_drop_member { DROP ROLE regress_role_member; } + +# GRANT role TO member - concurrent DROP of the member +permutation s1_begin s1_grant s2_drop_member s1_commit + +# ALTER ROLE ADD USER - concurrent DROP of the member +permutation s1_begin s1_alter_add s2_drop_member s1_commit + +# CREATE ROLE ... ROLE member - concurrent DROP of the member +permutation s1_begin s1_create_role s2_drop_member s1_commit + +# DROP OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_drop_owned s2_drop_member s1_commit + +# REASSIGN OWNED BY role - concurrent DROP of the role +permutation s1_begin s1_reassign_owned s2_drop_member s1_commit -- 2.34.1 --cOHldBwZEf1OqVbN-- ^ permalink raw reply [nested|flat] 221+ messages in thread
end of thread, other threads:[~2026-07-06 08:28 UTC | newest] Thread overview: 221+ messages (download: mbox mbox.gz follow: Atom feed) -- links below jump to the message on this page -- 2026-03-16 23:25 [PATCH v1 1/2] Support changing a column into a stored generated column Alberto Piai <[email protected]> 2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v3 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v4 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]> 2026-07-06 08:28 [PATCH v2 2/2] Protect role resolution in roleSpecsToIds() against concurrent DROP Bertrand Drouvot <[email protected]>
This inbox is served by agora; see mirroring instructions for how to clone and mirror all data and code used for this inbox