agora inbox for [email protected]help / color / mirror / Atom feed
[PATCH] Introduce an option to make logical replication database specific. 221+ messages / 2 participants [nested] [flat]
* [PATCH] Introduce an option to make logical replication database specific. @ 2026-04-03 10:34 Antonin Houska <[email protected]> 0 siblings, 0 replies; 221+ messages in thread From: Antonin Houska @ 2026-04-03 10:34 UTC (permalink / raw) By default, the logical decoding assumes access to shared catalogs, so the snapshot builder needs to consider cluster-wide XIDs during startup. That in turn means that, if any transaction is already running (and has XID assigned), the snapshot builder needs to wait for its completion, as it does not know if that transaction performed catalog changes earlier. Possible problem with this concept is that if REPACK (CONCURRENTLY) is running in some database, backends running the same command in other databases get stuck until the first one has committed. Thus only as single backend in the cluster can run REPACK (CONCURRENTLY) at any time. Likewise, REPACK (CONCURRENTLY) can block walsenders starting on behalf of subscriptions throughout the cluster. This patch introduces the possibility for a backend to declare that its output plugin does not use shared catalogs (i.e. catalogs that can be changed by transactions running in other databases in the cluster). In that case, no snapshot the backend will use during the decoding needs to contain information about transactions running in other databases. Thus the snapshot builder only needs to wait for completion of transactions in the current database. Currently we only use this option in the REPACK background worker. It could possibly be used in walsender involved in logical replication too, however that would need thorough analysis of its output plugin. The patch bumps WAL version number, due to a new field in xl_running_xacts. --- contrib/pg_visibility/pg_visibility.c | 4 ++-- src/backend/access/index/genam.c | 18 ++++++++++++++++ src/backend/access/rmgrdesc/standbydesc.c | 2 ++ src/backend/access/transam/xlog.c | 2 +- src/backend/access/transam/xlogfuncs.c | 2 +- src/backend/commands/repack_worker.c | 8 +++++++ src/backend/postmaster/bgwriter.c | 2 +- src/backend/replication/logical/snapbuild.c | 10 ++++++++- src/backend/replication/slot.c | 12 +++++++++-- src/backend/storage/ipc/procarray.c | 23 ++++++++++++++++++++- src/backend/storage/ipc/standby.c | 14 +++++++++++-- src/include/access/genam.h | 8 +++++++ src/include/access/xlog_internal.h | 2 +- src/include/storage/procarray.h | 2 +- src/include/storage/standby.h | 3 ++- src/include/storage/standbydefs.h | 1 + 16 files changed, 99 insertions(+), 14 deletions(-) diff --git a/contrib/pg_visibility/pg_visibility.c b/contrib/pg_visibility/pg_visibility.c index dfab0b64cf5..d564bd2a00c 100644 --- a/contrib/pg_visibility/pg_visibility.c +++ b/contrib/pg_visibility/pg_visibility.c @@ -621,7 +621,7 @@ GetStrictOldestNonRemovableTransactionId(Relation rel) else if (rel == NULL || rel->rd_rel->relisshared) { /* Shared relation: take into account all running xids */ - runningTransactions = GetRunningTransactionData(); + runningTransactions = GetRunningTransactionData(InvalidOid); LWLockRelease(ProcArrayLock); LWLockRelease(XidGenLock); return runningTransactions->oldestRunningXid; @@ -632,7 +632,7 @@ GetStrictOldestNonRemovableTransactionId(Relation rel) * Normal relation: take into account xids running within the current * database */ - runningTransactions = GetRunningTransactionData(); + runningTransactions = GetRunningTransactionData(InvalidOid); LWLockRelease(ProcArrayLock); LWLockRelease(XidGenLock); return runningTransactions->oldestDatabaseRunningXid; diff --git a/src/backend/access/index/genam.c b/src/backend/access/index/genam.c index 1408989c568..97ea882cf71 100644 --- a/src/backend/access/index/genam.c +++ b/src/backend/access/index/genam.c @@ -37,6 +37,14 @@ #include "utils/ruleutils.h" #include "utils/snapmgr.h" +/* + * If a backend is going to do logical decoding and if the output plugin does + * not need to access shared catalogs, setting this variable to false can make + * the decoding startup faster. In particular, the backend will not need to + * wait for completion of already running transactions in other databases. + */ +bool accessSharedCatalogsInDecoding = true; + /* ---------------------------------------------------------------- * general access method routines @@ -394,6 +402,16 @@ systable_beginscan(Relation heapRelation, SysScanDesc sysscan; Relation irel; + /* + * If this backend promised that it won't access shared catalogs during + * logical decoding, this seems to be the right place to check. + * + * XXX Should this be ereport(ERROR) ? + */ + Assert(!HistoricSnapshotActive() || + accessSharedCatalogsInDecoding || + !heapRelation->rd_rel->relisshared); + if (indexOK && !IgnoreSystemIndexes && !ReindexIsProcessingIndex(indexId)) diff --git a/src/backend/access/rmgrdesc/standbydesc.c b/src/backend/access/rmgrdesc/standbydesc.c index 0a291354ae2..685d1bdb024 100644 --- a/src/backend/access/rmgrdesc/standbydesc.c +++ b/src/backend/access/rmgrdesc/standbydesc.c @@ -41,6 +41,8 @@ standby_desc_running_xacts(StringInfo buf, xl_running_xacts *xlrec) for (i = 0; i < xlrec->subxcnt; i++) appendStringInfo(buf, " %u", xlrec->xids[xlrec->xcnt + i]); } + + appendStringInfo(buf, "; dbid: %u", xlrec->dbid); } void diff --git a/src/backend/access/transam/xlog.c b/src/backend/access/transam/xlog.c index 2c1c6f88b74..8da8eff93e4 100644 --- a/src/backend/access/transam/xlog.c +++ b/src/backend/access/transam/xlog.c @@ -7336,7 +7336,7 @@ CreateCheckPoint(int flags) * recovery we don't need to write running xact data. */ if (!shutdown && XLogStandbyInfoActive()) - LogStandbySnapshot(); + LogStandbySnapshot(InvalidOid); START_CRIT_SECTION(); diff --git a/src/backend/access/transam/xlogfuncs.c b/src/backend/access/transam/xlogfuncs.c index 65bbaeda59c..0f5979691e6 100644 --- a/src/backend/access/transam/xlogfuncs.c +++ b/src/backend/access/transam/xlogfuncs.c @@ -245,7 +245,7 @@ pg_log_standby_snapshot(PG_FUNCTION_ARGS) (errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE), errmsg("pg_log_standby_snapshot() can only be used if \"wal_level\" >= \"replica\""))); - recptr = LogStandbySnapshot(); + recptr = LogStandbySnapshot(InvalidOid); /* * As a convenience, return the WAL location of the last inserted record diff --git a/src/backend/commands/repack_worker.c b/src/backend/commands/repack_worker.c index 00b21ede481..c25dbeadff3 100644 --- a/src/backend/commands/repack_worker.c +++ b/src/backend/commands/repack_worker.c @@ -16,6 +16,7 @@ */ #include "postgres.h" +#include "access/genam.h" #include "access/table.h" #include "access/xlog_internal.h" #include "access/xlogutils.h" @@ -233,6 +234,13 @@ repack_setup_logical_decoding(Oid relid) EnsureLogicalDecodingEnabled(); + /* + * By declaring that our output plugin does not need shared catalogs, we + * avoid waiting for completion of transactions running in other databases + * than the one we're connected to. + */ + accessSharedCatalogsInDecoding = false; + /* * Neither prepare_write nor do_write callback nor update_progress is * useful for us. diff --git a/src/backend/postmaster/bgwriter.c b/src/backend/postmaster/bgwriter.c index 1d8947774a9..a30de4262eb 100644 --- a/src/backend/postmaster/bgwriter.c +++ b/src/backend/postmaster/bgwriter.c @@ -289,7 +289,7 @@ BackgroundWriterMain(const void *startup_data, size_t startup_data_len) if (now >= timeout && last_snapshot_lsn <= GetLastImportantRecPtr()) { - last_snapshot_lsn = LogStandbySnapshot(); + last_snapshot_lsn = LogStandbySnapshot(InvalidOid); last_snapshot_ts = now; } } diff --git a/src/backend/replication/logical/snapbuild.c b/src/backend/replication/logical/snapbuild.c index 0f6b5df322c..16009fa75a4 100644 --- a/src/backend/replication/logical/snapbuild.c +++ b/src/backend/replication/logical/snapbuild.c @@ -125,6 +125,7 @@ #include <sys/stat.h> #include <unistd.h> +#include "access/genam.h" #include "access/heapam_xlog.h" #include "access/transam.h" #include "access/xact.h" @@ -1470,7 +1471,14 @@ SnapBuildWaitSnapshot(xl_running_xacts *running, TransactionId cutoff) */ if (!RecoveryInProgress()) { - LogStandbySnapshot(); + Oid dbid; + + /* + * Only consider transactions of the current database if our plugin is + * not supposed to access shared catalogs. + */ + dbid = accessSharedCatalogsInDecoding ? InvalidOid : MyDatabaseId; + LogStandbySnapshot(dbid); } } diff --git a/src/backend/replication/slot.c b/src/backend/replication/slot.c index fe6bfeba25e..0d415f5f8f2 100644 --- a/src/backend/replication/slot.c +++ b/src/backend/replication/slot.c @@ -39,6 +39,7 @@ #include <unistd.h> #include <sys/stat.h> +#include "access/genam.h" #include "access/transam.h" #include "access/xlog_internal.h" #include "access/xlogrecovery.h" @@ -1776,9 +1777,16 @@ ReplicationSlotReserveWal(void) if (!RecoveryInProgress() && SlotIsLogical(slot)) { XLogRecPtr flushptr; + Oid dbid; - /* make sure we have enough information to start */ - flushptr = LogStandbySnapshot(); + /* + * Make sure we have enough information to start. + * + * Only consider transactions of the current database if our plugin + * is not supposed to access shared catalogs. + */ + dbid = accessSharedCatalogsInDecoding ? InvalidOid : MyDatabaseId; + flushptr = LogStandbySnapshot(dbid); /* and make sure it's fsynced to disk */ XLogFlush(flushptr); diff --git a/src/backend/storage/ipc/procarray.c b/src/backend/storage/ipc/procarray.c index cc207cb56e3..49d1bf4bfac 100644 --- a/src/backend/storage/ipc/procarray.c +++ b/src/backend/storage/ipc/procarray.c @@ -2631,9 +2631,11 @@ ProcArrayInstallRestoredXmin(TransactionId xmin, PGPROC *proc) * * Note that if any transaction has overflowed its cached subtransactions * then there is no real need include any subtransactions. + * + * If 'dbid' is valid, only gather transactions running in that database. */ RunningTransactions -GetRunningTransactionData(void) +GetRunningTransactionData(Oid dbid) { /* result workspace */ static RunningTransactionsData CurrentRunningXactsData; @@ -2708,6 +2710,18 @@ GetRunningTransactionData(void) if (!TransactionIdIsValid(xid)) continue; + /* + * Filter by database OID if requested. + */ + if (OidIsValid(dbid)) + { + int pgprocno = arrayP->pgprocnos[index]; + PGPROC *proc = &allProcs[pgprocno]; + + if (proc->databaseId != dbid) + continue; + } + /* * Be careful not to exclude any xids before calculating the values of * oldestRunningXid and suboverflowed, since these are used to clean @@ -2758,6 +2772,12 @@ GetRunningTransactionData(void) PGPROC *proc = &allProcs[pgprocno]; int nsubxids; + /* + * Filter by database OID if requested. + */ + if (OidIsValid(dbid) && proc->databaseId != dbid) + continue; + /* * Save subtransaction XIDs. Other backends can't add or remove * entries while we're holding XidGenLock. @@ -2791,6 +2811,7 @@ GetRunningTransactionData(void) * increases if slots do. */ + CurrentRunningXacts->dbid = dbid; CurrentRunningXacts->xcnt = count - subcount; CurrentRunningXacts->subxcnt = subcount; CurrentRunningXacts->subxid_status = suboverflowed ? SUBXIDS_IN_SUBTRANS : SUBXIDS_IN_ARRAY; diff --git a/src/backend/storage/ipc/standby.c b/src/backend/storage/ipc/standby.c index de9092fdf5b..c653ea742bc 100644 --- a/src/backend/storage/ipc/standby.c +++ b/src/backend/storage/ipc/standby.c @@ -1188,6 +1188,14 @@ standby_redo(XLogReaderState *record) xl_running_xacts *xlrec = (xl_running_xacts *) XLogRecGetData(record); RunningTransactionsData running; + /* + * Records issued for specific database are not suitable for physical + * replication because that affects the whole cluster. In particular, + * the list of XID is probably incomplete here. + */ + if (OidIsValid(xlrec->dbid)) + return; + running.xcnt = xlrec->xcnt; running.subxcnt = xlrec->subxcnt; running.subxid_status = xlrec->subxid_overflow ? SUBXIDS_MISSING : SUBXIDS_IN_ARRAY; @@ -1277,11 +1285,12 @@ standby_redo(XLogReaderState *record) * as there's no independent knob to just enable logical decoding. For * details of how this is used, check snapbuild.c's introductory comment. * + * If 'dbid' is valid, only gather transactions running in that database. * * Returns the RecPtr of the last inserted record. */ XLogRecPtr -LogStandbySnapshot(void) +LogStandbySnapshot(Oid dbid) { XLogRecPtr recptr; RunningTransactions running; @@ -1314,7 +1323,7 @@ LogStandbySnapshot(void) * Log details of all in-progress transactions. This should be the last * record we write, because standby will open up when it sees this. */ - running = GetRunningTransactionData(); + running = GetRunningTransactionData(dbid); /* * GetRunningTransactionData() acquired ProcArrayLock, we must release it. @@ -1358,6 +1367,7 @@ LogCurrentRunningXacts(RunningTransactions CurrRunningXacts) xl_running_xacts xlrec; XLogRecPtr recptr; + xlrec.dbid = CurrRunningXacts->dbid; xlrec.xcnt = CurrRunningXacts->xcnt; xlrec.subxcnt = CurrRunningXacts->subxcnt; xlrec.subxid_overflow = (CurrRunningXacts->subxid_status != SUBXIDS_IN_ARRAY); diff --git a/src/include/access/genam.h b/src/include/access/genam.h index b69320a7fc8..56c573399ab 100644 --- a/src/include/access/genam.h +++ b/src/include/access/genam.h @@ -136,6 +136,14 @@ typedef struct IndexOrderByDistance bool isnull; } IndexOrderByDistance; +/* + * Is the backend interested in shared catalogs when performing logical + * decoding? + * + * XXX Is there a better place for this declaration? + */ +extern bool accessSharedCatalogsInDecoding; + /* * generalized index_ interface routines (in indexam.c) */ diff --git a/src/include/access/xlog_internal.h b/src/include/access/xlog_internal.h index 755835d63bf..ae19982d88d 100644 --- a/src/include/access/xlog_internal.h +++ b/src/include/access/xlog_internal.h @@ -31,7 +31,7 @@ /* * Each page of XLOG file has a header like this: */ -#define XLOG_PAGE_MAGIC 0xD11E /* can be used as WAL version indicator */ +#define XLOG_PAGE_MAGIC 0xD11F /* can be used as WAL version indicator */ typedef struct XLogPageHeaderData { diff --git a/src/include/storage/procarray.h b/src/include/storage/procarray.h index abdf021e66e..377b3060b9f 100644 --- a/src/include/storage/procarray.h +++ b/src/include/storage/procarray.h @@ -49,7 +49,7 @@ extern bool ProcArrayInstallImportedXmin(TransactionId xmin, VirtualTransactionId *sourcevxid); extern bool ProcArrayInstallRestoredXmin(TransactionId xmin, PGPROC *proc); -extern RunningTransactions GetRunningTransactionData(void); +extern RunningTransactions GetRunningTransactionData(Oid dbid); extern bool TransactionIdIsInProgress(TransactionId xid); extern TransactionId GetOldestNonRemovableTransactionId(Relation rel); diff --git a/src/include/storage/standby.h b/src/include/storage/standby.h index 6a314c693cd..8715c08e94f 100644 --- a/src/include/storage/standby.h +++ b/src/include/storage/standby.h @@ -126,6 +126,7 @@ typedef enum typedef struct RunningTransactionsData { + Oid dbid; /* only track xacts in this database */ int xcnt; /* # of xact ids in xids[] */ int subxcnt; /* # of subxact ids in xids[] */ subxids_array_status subxid_status; @@ -143,7 +144,7 @@ typedef RunningTransactionsData *RunningTransactions; extern void LogAccessExclusiveLock(Oid dbOid, Oid relOid); extern void LogAccessExclusiveLockPrepare(void); -extern XLogRecPtr LogStandbySnapshot(void); +extern XLogRecPtr LogStandbySnapshot(Oid dbid); extern void LogStandbyInvalidations(int nmsgs, SharedInvalidationMessage *msgs, bool relcacheInitFileInval); diff --git a/src/include/storage/standbydefs.h b/src/include/storage/standbydefs.h index 231d251fd51..e75b7078766 100644 --- a/src/include/storage/standbydefs.h +++ b/src/include/storage/standbydefs.h @@ -46,6 +46,7 @@ typedef struct xl_standby_locks */ typedef struct xl_running_xacts { + Oid dbid; /* only track xacts in this database */ int xcnt; /* # of xact ids in xids[] */ int subxcnt; /* # of subxact ids in xids[] */ bool subxid_overflow; /* snapshot overflowed, subxids missing */ -- 2.47.3 --=-=-=-- ^ 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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
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-04-03 10:34 [PATCH] Introduce an option to make logical replication database specific. Antonin Houska <[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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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]>
This inbox is served by agora; see mirroring instructions for how to clone and mirror all data and code used for this inbox